From fe3b61895e3f4ee503ecbd79db7b1b60e7818cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 01:11:31 +0100 Subject: [PATCH 1/4] fix(tauri): resolve five dangling Windows lockfile edges The current workspace lockfile references windows-sys 0.60.2 in muda, socket2, tray-icon, uds_windows and window-vibrancy, but contains no package record for that version. Cargo validates the whole lock graph before filtering by target, so both Linux and macOS reject it with --locked. The inconsistent edges entered dev with the WebKit preview merge and are independent of the npm dependency catch-up. Point only these five edges at the already-locked windows-sys 0.61.2, which every parent's declared range accepts. No package versions, sources, checksums, other dependency edges or vendored patch selections change. Retain all --locked gates. The original reproduces the refusal with a fresh private Cargo cache. Corrected cargo metadata --locked --offline succeeds with macOS ARM64, Linux x64 and Windows x64 target filters; six existing vendor tests pass. Independent byte comparison confirms exactly five substitutions, and the three locked metadata checks were independently reproduced. Native compilation remains for CI. --- packages/tauri-app/Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index b446603b6..d47030c96 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -2515,7 +2515,7 @@ dependencies = [ "png", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3995,7 +3995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4989,7 +4989,7 @@ dependencies = [ "png", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5024,7 +5024,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5473,7 +5473,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "windows-version", ] From dcb0136531c5d4f24ec9f19bd57a83c112832698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 03:16:13 +0100 Subject: [PATCH 2/4] test(missions): await the partial result's row publication before continuing Failure-time CI evidence shows Daily's Pause disabled at pointer-down and pointer-up with no click, after the other row's partial Pause. The helper awaited local unconfirmed feedback, but that result also starts a row refresh; its loading guard can disable actions between Playwright's actionability check and pointer delivery. Wait for the owning row to publish Paused or Stopped before advancing the next test action. Keep every existing assertion, deadline and retry unchanged; no forced click or product change. A held read proves the original helper returned too early, while the corrected helper waits for publication. The 22 targeted browser tests pass, independently rerun on Node 24.22.2. --- .../ui/tests/browser/mission-recurrence-retry-order.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/tests/browser/mission-recurrence-retry-order.test.ts b/packages/ui/tests/browser/mission-recurrence-retry-order.test.ts index 27db85c0e..ae5a11bb6 100644 --- a/packages/ui/tests/browser/mission-recurrence-retry-order.test.ts +++ b/packages/ui/tests/browser/mission-recurrence-retry-order.test.ts @@ -6,7 +6,7 @@ import { borrowFixture } from "./fixture-borrow" import { createMissionFailureDiagnostics } from "./mission-failure-diagnostics" import { chromium, type Browser, type Page, type Route } from "playwright" import type {} from "./fixtures/mission-editor-lifetime" -import { clickMissionAction, missionRefresh, missionToolbarAction, selectMission } from "./mission-actions" +import { clickMissionAction, missionRefresh, missionRow, missionToolbarAction, selectMission } from "./mission-actions" import type { RecurrenceSchedule } from "../../src/stores/mission-recurrence" import { recurrenceSnapshotSchema, recurrenceControlHttpSchema, recurrenceControlStatusSchema } from "../../../server/src/missions/recurrence-control-contract" @@ -110,6 +110,8 @@ async function holdPartial(page: Page, action: "pause" | "stop", title = TITLE) await missionToolbarAction(page, CHECK(title)).waitFor() await page.locator(".mission-picker-selection-actions .mission-action-feedback [role=status]").getByText(UNCONFIRMED, { exact: true }).waitFor() assert.equal(await missionRefresh(page).getAttribute("aria-label"), RESEND) + // The partial result schedules a row read; feedback can precede its publication. + await missionRow(page, title).locator(".sr-only", { hasText: action === "pause" ? /^Daily · Paused/ : /^Daily · Stopped/ }).waitFor({ state: "attached" }) } /** Starts an event-driven row read that stays in flight until released. */ async function rowReadInFlight(page: Page, state: Awaited>["state"]) { From dce6c8ea22d192777579fc573699476b2fd644c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 04:42:36 +0100 Subject: [PATCH 3/4] fix(ci): keep manifest rendering in its owner and align compact-form fixtures The baseline restore-ownership command now imports a browser-only icon through the core Status manifest. Keep that factory dependent only on its existing renderer inputs: its UI owner supplies the real info-tip renderer, alongside the existing body and summary renderers. This preserves help destinations and locale handling without stubbing the icon or adding a test loader. Align three browser fixtures with the merged compact Missions form: serve the required valid depth response, interact with directly visible controls instead of the removed Options disclosure, and await the rendered coordinator-model catalog before Create disposes its readers. Keep the strict cancellation collector and every ownership, no-replay, geometry and destination assertion. The full restore-ownership command passes 657 tests locally. Independently repeated manifest/registry tests pass 12/12, and the combined headless Missions/help run passes 47/47 on Node 24.22.2. A held catalog reproduces the original failed-request mismatch and passes after the readiness check. Reused Solid differs by a patch from the lockfile; exact dependency qualification remains for CI. The macOS pruning navigation timeout remains separately unresolved. --- dev-docs/UI_SHELL.md | 2 +- .../instance/shell/right-panel/core-plugin.test.ts | 7 ++++++- .../instance/shell/right-panel/core-plugin.tsx | 5 ++--- .../instance/shell/right-panel/tabs/StatusTab.tsx | 2 ++ packages/ui/tests/browser/automation-forms.test.ts | 10 ++++------ .../ui/tests/browser/fixtures/settings-info-tip.tsx | 3 ++- .../ui/tests/browser/mission-creation-start.test.ts | 1 + packages/ui/tests/browser/mission-navigation.test.ts | 2 ++ 8 files changed, 20 insertions(+), 12 deletions(-) diff --git a/dev-docs/UI_SHELL.md b/dev-docs/UI_SHELL.md index c67c03098..5fc94b947 100644 --- a/dev-docs/UI_SHELL.md +++ b/dev-docs/UI_SHELL.md @@ -17,7 +17,7 @@ Panel toggles, floating drawers, project/right-panel tabs, the session sidebar a - Session archive actions use native `session.update({ archived })`, serialized with other session actions, followed by an authoritative inventory refresh and flag verification. Native events project `time.archived`; stale metadata reads retain newer event authority. Normal browsing and list-cycling hide idle archived branches, keeping selected conversations and running/pending-request paths reachable. Children retain their own flags and remain independently discoverable in flat search/filter mode. The archive selector is local to that search surface (Unarchived / Archived / All), with the native flag badge and Restore action; never persist a separate archive state or treat archive as interruption/deletion. Validate `session-archive.test.ts`, `session-tree.test.ts`, browser `session-archives.test.ts` and `scripts/test-session-archive-native.mjs`. - Project and right-panel tabs share `components/tab-scroll.tsx` and `styles/components/tab-scroll.css`. Keep their native scrollbar above upright content without mirrored transforms, negative border overlaps or permanent compositing hints. Validate shared scrollbar styling and adjoining edges at fractional zoom in the browser and isolated Electron renderer fixtures (`tests/browser/tab-chrome.test.ts`). - The Status tab starts with the shown session's goal line (`components/session/session-goal-line.tsx`, `styles/panels/status-goal.css`) only while that session has a `/goal`: one row above the sections with the status badge (`.session-goal-badge` in `styles/components/badges.css`), the objective truncated with its full text and the reason in its tooltip (the reason is also the row's accessible description), the rounds, one Pause/Resume button that keeps focus as the status flips, and Clear. While a command runs its actions are `aria-disabled` and ignore activation, never `disabled`, so the focused button keeps keyboard focus. Never add it to `CORE_STATUS_SECTION_ITEMS` or the right-panel customization, and never show a hint, empty state or gap without a goal. Contract, commands and freshness: [OPENCODE_COMPATIBILITY.md](OPENCODE_COMPATIBILITY.md) (Session goal). Validate with `tests/browser/session-goal.test.ts`. -- Built-in right-panel tabs are constructed directly in `right-panel/core-runtime.tsx` (never add a first-party plugin loader for them); external sandboxed panels keep their independent consent/loading path. +- Built-in right-panel tabs are constructed directly in `right-panel/core-runtime.tsx` (never add a first-party plugin loader for them); external sandboxed panels keep their independent consent/loading path. Core manifest factories receive rendering callbacks, including info tips, from their owning UI. - Integration Status sections are registered by `right-panel/integration-status-module.tsx` under stable `integration:` IDs, in catalog order after Plugins. Saved order and hidden IDs retain absent sections; dragging moves only the source, and Up/Down in customization uses the same ordering for every section. Stable section objects preserve focused DOM across refreshes. Expected-action changes expand once, respecting subsequent user collapse. One attachment owner and one grouped polite live region belong to each Status tab; disappearing focused sections hand focus to the Plugins accordion trigger. Conversation attachment controls and binding labels live exclusively in these sections. - Embedded floating session drawers use `instance/shell/HostedDrawer.tsx` so their SUID modal roots are direct children of the existing geometry host. A Solid Portal wrapper must not become an ARIA-hidden ancestor of visible controls. Preserve SUID's modal stacking, sibling isolation, focus policy, Escape/backdrop handling and scroll restoration; validate with `tests/browser/drawer-accessibility.test.ts` rather than overriding ARIA or disabling modal enforcement. - Floating-panel outside-pointer and Escape dismissal leave ARIA menus, listboxes and dialogs to their own selection/dismissal handlers (`shell/drawer-events.ts`). Their portals are outside the drawer DOM, and closing the drawer on pointerdown destroys a menu before touch pointerup selects its action. Both the shell's capture listener and SUID's Escape policy defer to nested surfaces; focus and isolation stay with SUID. Keep this guard shared in the shell, not in individual menu actions; validate the real session Delete confirmation with `tests/browser/session-delete.test.ts` and Status menus with `tests/browser/integration-attachment.test.ts`. diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.test.ts b/packages/ui/src/components/instance/shell/right-panel/core-plugin.test.ts index 5a7bbadbc..645455996 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.test.ts @@ -5,14 +5,19 @@ import { createCoreRightPanelModule, createCoreStatusSectionManifest } from "./c it("defines built-in tabs and status sections without a plugin loading layer", () => { const render = () => undefined as any const summary = () => undefined as any + const info: string[] = [] const module = createCoreRightPanelModule({ renderFilesTab: render, renderStatusTab: render }) const sections = createCoreStatusSectionManifest({ renderTokens: render, renderTokensSummary: summary, renderYoloModeSection: render, - renderProviderUsage: render, renderBackgroundProcesses: render, renderMcpStatus: render, renderPluginStatus: render }) + renderProviderUsage: render, renderBackgroundProcesses: render, renderMcpStatus: render, renderPluginStatus: render, + renderInfo: section => { info.push(section.id); return section.id } }) assert.deepEqual(module.tabs?.map(entry => entry.id), ["files", "status"]) assert.equal(module.tabs?.find(entry => entry.id === "status")?.alwaysVisible, true) assert.deepEqual(sections.statusSections?.map(entry => entry.id), ["tokens", "yolo-mode", "provider-usage", "background-processes", "mcp", "plugins"]) assert.equal(module.tabs?.every(entry => entry.render === render), true) assert.equal(sections.statusSections?.every(entry => entry.render === render), true) + assert.deepEqual(info, [], "building the manifest never renders components") + assert.deepEqual(sections.statusSections?.map(entry => entry.renderInfo?.()), sections.statusSections?.map(entry => entry.id)) + assert.deepEqual(info, sections.statusSections?.map(entry => entry.id), "each info renderer receives its own section") assert.equal(sections.statusSections?.find(entry => entry.id === "tokens")?.renderSummary, summary) assert.equal(sections.statusSections?.filter(entry => entry.id !== "tokens").every(entry => !entry.renderSummary), true) }) diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx index 5055f45a6..332ade4ed 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -2,8 +2,6 @@ import type { JSX } from "solid-js" import type { RightPanelModule } from "./registry" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" -import { SettingsInfoTip } from "../../../settings/settings-info-tip" -import { tGlobal } from "../../../../lib/i18n" interface CoreRightPanelRenderers { renderFilesTab: () => JSX.Element @@ -11,6 +9,7 @@ interface CoreRightPanelRenderers { } interface CoreStatusSectionRenderers { + renderInfo: (section: typeof CORE_STATUS_SECTION_ITEMS[number]) => JSX.Element renderTokens: () => JSX.Element renderTokensSummary?: () => JSX.Element renderYoloModeSection: () => JSX.Element @@ -63,7 +62,7 @@ export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRend const render = sectionRenderers[section.id] if (!render) throw new Error(`Missing core right panel section renderer: ${section.id}`) return { ...section, render, renderSummary: section.id === "tokens" ? renderers.renderTokensSummary : undefined, - renderInfo: () => } + renderInfo: () => renderers.renderInfo(section) } }), } } diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 2cbd66453..2abcae417 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -29,6 +29,7 @@ import { shellStore } from "../../../../../stores/shells" import { showConfirmDialog } from "../../../../../stores/alerts" import { showToastNotification } from "../../../../../lib/notifications" import { ShellOutputDialog } from "../../../../shell-output-dialog" +import { SettingsInfoTip } from "../../../../settings/settings-info-tip" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -239,6 +240,7 @@ const StatusTab: Component = (props) => { // Built once: a changed extra-section list must never recreate (remount) the core sections. const coreSections = untrack(() => createCoreStatusSectionManifest({ + renderInfo: section => , renderTokens: () => ( diff --git a/packages/ui/tests/browser/automation-forms.test.ts b/packages/ui/tests/browser/automation-forms.test.ts index ae4493474..4f1e43514 100644 --- a/packages/ui/tests/browser/automation-forms.test.ts +++ b/packages/ui/tests/browser/automation-forms.test.ts @@ -182,7 +182,7 @@ test("an agent fills the real Mission editor: the title is replaced, not concate await page.addInitScript(desktopWindow) await page.route("**/api/**", route => route.fulfill({ json: {} })) await page.route("**/api/storage/config/ui", route => route.fulfill({ json: { settings: { missionProfileDefaults: [{ template: "all", profiles }] } } })) - // Creation Options embed the project's nesting depth: answer its read like a native runtime. + // Creation embeds the project's nesting depth: answer its read like a native runtime. await page.route("**/subagent-depth*", route => route.fulfill({ json: { location: { directory: "/fixture" }, capability: { minimum: 0 }, effectiveDepth: 1, project: { path: "/fixture/.opencode/opencode.jsonc", depth: null, expectation: "e" } } })) let list: MissionMap[] = [] @@ -212,12 +212,11 @@ test("an agent fills the real Mission editor: the title is replaced, not concate await act({ action: "type", ref: title, text: "First draft" }) assert.deepEqual(await act({ action: "type", ref: title, text: "Seam survey" }), { value: "Seam survey" }, "a second type replaces the first") assert.deepEqual(await act({ action: "type", ref: title, text: " (pilot)", clear: false }), { value: "Seam survey (pilot)" }) - await act({ action: "click", ref: await refFor(/DisclosureTriangle "Options"/, "Options") }) assert.deepEqual(await act({ action: "select", ref: await refFor(/combobox "Playbook"/, "Playbook"), values: ["Exploration"] }), { selected: ["Exploration"] }) assert.match((await run.cdp.inspect(run.identity, { query: "Playbook" })).snapshot, /combobox "Playbook" \[e\d+\] value="Exploration"/) await page.waitForFunction(() => !(document.querySelector('form.mission-editor button[type="submit"]') as HTMLButtonElement)?.disabled) assert.equal(await page.getByLabel("Title", { exact: true }).inputValue(), "Seam survey (pilot)") - // Like an agent, wait for the Options section to finish reading before clicking under it. + // Like an agent, wait for the depth row to finish reading before clicking under it. await act({ action: "wait", text: "Inherit (1)" }) await act({ action: "click", ref: await refFor(/button "Create"(?= \[)/, "Create") }) await page.waitForFunction(() => document.querySelector("form.mission-editor") === null) @@ -227,7 +226,7 @@ test("an agent fills the real Mission editor: the title is replaced, not concate } finally { await run.close() } }) -// The controller aims once, then dispatches: Options' first depth result, or a mission map +// The controller aims once, then dispatches: creation's first depth result, or a mission map // refresh adding a panel banner while the form is open, may land in between. const existing = { ...createdMission({ title: "Existing mission", objective: "Existing mission", template: "custom" }), id: "msn_existing" } const map = (extra: Record = {}) => ({ json: { available: true, projectID: "project", missions: panelMissions([existing]), generatedAt: 1, discardedEvents: 0, ...extra } }) @@ -283,8 +282,7 @@ for (const outcome of HELD_OUTCOMES) test(`Create keeps its place when ${outcome const { page, act, refFor } = run await act({ action: "click", ref: await refFor(/button "Create mission"/, "Create mission") }) await act({ action: "type", ref: await refFor(/textbox "What should the mission do\?"/, "What should the mission do?"), text: "Hold a read" }) - if (outcome.held === "depth") await act({ action: "click", ref: await refFor(/DisclosureTriangle "Options"/, "Options") }) - else { holdMap = true; await page.evaluate(() => window.missionEditorLifetime.invalidate()) } + if (outcome.held !== "depth") { holdMap = true; await page.evaluate(() => window.missionEditorLifetime.invalidate()) } await requested.promise await page.waitForFunction(() => !(document.querySelector('form.mission-editor button[type="submit"]') as HTMLButtonElement)?.disabled) const create = page.locator('form.mission-editor button[type="submit"]'), before = await create.boundingBox() diff --git a/packages/ui/tests/browser/fixtures/settings-info-tip.tsx b/packages/ui/tests/browser/fixtures/settings-info-tip.tsx index b55dd7cf8..739d2c291 100644 --- a/packages/ui/tests/browser/fixtures/settings-info-tip.tsx +++ b/packages/ui/tests/browser/fixtures/settings-info-tip.tsx @@ -19,7 +19,8 @@ const [checked, setChecked] = createSignal(false) function Example() { const { t } = useI18n() const sections = createCoreStatusSectionManifest({ renderTokens: () => null, renderYoloModeSection: () => null, - renderProviderUsage: () => null, renderBackgroundProcesses: () => null, renderMcpStatus: () => null, renderPluginStatus: () => null }).statusSections + renderProviderUsage: () => null, renderBackgroundProcesses: () => null, renderMcpStatus: () => null, renderPluginStatus: () => null, + renderInfo: section => }).statusSections return
setLeaked(n => n + 1)} onPointerDown={() => setLeaked(n => n + 1)} onKeyDown={() => setLeaked(n => n + 1)}> diff --git a/packages/ui/tests/browser/mission-creation-start.test.ts b/packages/ui/tests/browser/mission-creation-start.test.ts index f4690ed92..14beec0bd 100644 --- a/packages/ui/tests/browser/mission-creation-start.test.ts +++ b/packages/ui/tests/browser/mission-creation-start.test.ts @@ -201,6 +201,7 @@ test("real editor adopts discovered project before creation; lost Play remains r assert.equal(await form.getByLabel("What should the mission do?", { exact: true }).inputValue(), "Inspect changes after project discovery") await form.getByLabel("Criterion 1 type", { exact: true }).selectOption("daily") await form.getByRole("checkbox", { name: "Start on creation", exact: true }).check() + await form.getByLabel("Coordinator · Model", { exact: true }).locator("option:checked").filter({ hasText: "Fixture model" }).waitFor({ state: "attached" }) await form.getByRole("button", { name: "Create", exact: true }).click() await form.waitFor({ state: "detached" }); await saved(page) diff --git a/packages/ui/tests/browser/mission-navigation.test.ts b/packages/ui/tests/browser/mission-navigation.test.ts index d86d603cd..ccc40115a 100644 --- a/packages/ui/tests/browser/mission-navigation.test.ts +++ b/packages/ui/tests/browser/mission-navigation.test.ts @@ -124,6 +124,8 @@ async function setup(missing = false, holdSchedule = false) { const pathname = new URL(route.request().url()).pathname requests.push(`${route.request().method()} ${pathname}`) if (pathname === "/api/events") return route.fulfill({ contentType: "text/event-stream", body: ": fixture\n\n" }) + if (pathname.endsWith("/subagent-depth")) return route.fulfill({ json: { location: { directory: "/fixture" }, + capability: { minimum: 0 }, effectiveDepth: 1, project: { path: "/fixture/.opencode/opencode.jsonc", depth: null, expectation: "e" } } }) if (pathname.endsWith("/missions/scheduled")) return route.fulfill({ json: { version: 1, projectID: "project", schedules: [] } }) if (pathname.endsWith("/missions/A/schedule") && holdSchedule) { scheduleReached.release(); await scheduleHeld.promise From fc6b4715412efb19eedbaa87f69602162e3b0aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 06:16:05 +0100 Subject: [PATCH 4/4] test(missions): observe both catalog waves before invalidating their owner The strict failed-request collector retained cancellations from project discovery, not just Create. A later remembered model option could not prove that either the initial or replacement catalog reader had completed. Separately, the test fixture awaited its display fetch before retiring the saved editor, allowing another catalog read that production MissionControl never starts. Passively retain exact request-finished receipts during navigation, consume the initial wave after fixture load, and immediately own the discovery wave with Promise.all around its trigger. Close the fixture editor synchronously before its display fetch, matching the real UI owner. Keep the existing assertions, cancellation collector and default navigation/action bounds unchanged; no forced clicks, sleeps or failed-request filters. All six controlled original-head failure scenarios pass with the correction, including either agent/model completion order and late display reads. Seven targeted tests pass with exact Node 24.22.2/npm 11.19.1/Solid 1.9.18 dependencies, independently repeated. Virtual-clock controls confirm navigation is not shortened by prematurely armed action timeouts. --- .../fixtures/mission-creation-start.tsx | 4 +++- .../browser/mission-creation-start.test.ts | 24 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/ui/tests/browser/fixtures/mission-creation-start.tsx b/packages/ui/tests/browser/fixtures/mission-creation-start.tsx index 92845342d..9c51beac9 100644 --- a/packages/ui/tests/browser/fixtures/mission-creation-start.tsx +++ b/packages/ui/tests/browser/fixtures/mission-creation-start.tsx @@ -80,9 +80,11 @@ function RealEditorFixture() { true} onCancel={() => setEditing(false)} onSaved={async (_mission, id) => { if (!id) throw new Error("Expected calendar-owned creation") + // Match MissionControl: retire the editor synchronously before reading its created document. + setEditing(false) const read = await serverApi.fetchMissionSchedule("fixture", id, { directory: "/fixture" }) if (!read.document) throw new Error("Created Mission unavailable") - setDoc(read.document); setEditing(false); setSaved(value => value + 1) + setDoc(read.document); setSaved(value => value + 1) }} /> diff --git a/packages/ui/tests/browser/mission-creation-start.test.ts b/packages/ui/tests/browser/mission-creation-start.test.ts index 14beec0bd..0a9266ad7 100644 --- a/packages/ui/tests/browser/mission-creation-start.test.ts +++ b/packages/ui/tests/browser/mission-creation-start.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { after, before, test } from "node:test" import { createServer, type Server } from "node:http" -import { chromium, type Browser, type Page } from "playwright" +import { chromium, type Browser, type Page, type Request } from "playwright" import { borrowFixture } from "./fixture-borrow" import { armFixtureNavigationBudget } from "./mission-panel-route" import { NativeMissionDocumentStore } from "../../../server/src/missions/mission-document-store" @@ -101,9 +101,24 @@ async function setup(page: Page, options: { enabled?: boolean; lose?: "create" | await page.route("**/api/workspaces/fixture/missions", route => route.fulfill({ json: { available: true, version: 1, projectID: "project", missions: [], cleanups: [], generatedAt: 1, discardedEvents: 0 } })) } + const kinds = ["agent", "model", "provider"], completed: Request[] = [] + const matches = (request: Request, kind: string) => request.method() === "GET" + && new URL(request.url()).pathname === `/workspaces/fixture/instance/api/${kind}` + // Capture body-completion receipts without starting action deadlines during navigation. + const remember = (request: Request) => { if (kinds.some(kind => matches(request, kind))) completed.push(request) } + page.on("requestfinished", remember) + const waitForCatalog = () => { + page.off("requestfinished", remember) + const early = completed.splice(0) // Initial receipts are consumed once; later waves require new events. + return Promise.all(kinds.map(async kind => { + if (!early.some(request => matches(request, kind))) await page.waitForEvent("requestfinished", { predicate: request => matches(request, kind) }) + })) + } await page.goto(options.realEditor ? `${url}?realEditor=1` : url) await page.waitForFunction(realEditor => realEditor ? (window as any).realCreationStartFixture : (window as any).creationStartFixture, !!options.realEditor) - return { posts, reads, failedRequests, errors, get revision() { return doc.revision }, set activationGate(value: Promise) { activationGate = value } } + if (options.realEditor) await waitForCatalog() + else page.off("requestfinished", remember) + return { posts, reads, failedRequests, errors, waitForCatalog, get revision() { return doc.revision }, set activationGate(value: Promise) { activationGate = value } } } const save = (page: Page, requested = true) => page.evaluate(({ configuration, requested }) => (window as any).creationStartFixture.save(configuration, requested), { configuration, requested }) @@ -194,7 +209,10 @@ test("real editor adopts discovered project before creation; lost Play remains r assert.ok(originalForm) assert.equal(await page.locator("[data-project]").textContent(), "undiscovered") await form.getByLabel("What should the mission do?", { exact: true }).fill("Inspect changes after project discovery") - await page.evaluate(() => (window as any).realCreationStartFixture.discoverProject()) + await Promise.all([ + f.waitForCatalog(), + page.evaluate(() => (window as any).realCreationStartFixture.discoverProject()), + ]) await page.getByText("project", { exact: true }).waitFor() assert.equal(await originalForm.evaluate(element => element.isConnected && element === document.querySelector("form.mission-editor")), true, "project discovery must update the already-mounted MissionEditor")