From a0d81c4b526e7302bad3e771d3ba1741d9fd8921 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 15 Sep 2026 00:51:10 +0200 Subject: [PATCH] test(ci): consolidate journeys and remove repeated startup work Run user journeys with shared setup, retain one Hub CLI contract, and weight the existing Playwright shards using observed costs. Remove implementation-only and ad-hoc measurement checks. Await root-watch closure and publish creation failure commands atomically so teardown and failure injection have completion boundaries. --- .github/workflows/ci.yml | 3 + .../browser/agent-message-submission.spec.ts | 22 +- .../app/e2e/browser/agent-stream-ui.spec.ts | 85 --- .../browser/agent-timeline-pagination.spec.ts | 86 +-- packages/app/e2e/browser/changes-pane.spec.ts | 155 ++--- .../e2e/browser/command-center-host.spec.ts | 33 -- .../browser/command-center-workspaces.spec.ts | 8 + .../e2e/browser/composer-attachments.spec.ts | 125 +--- .../file-explorer-context-actions.spec.ts | 15 - .../app/e2e/browser/host-appearance.spec.ts | 123 ++-- packages/app/e2e/browser/launcher-tab.spec.ts | 175 +----- .../new-workspace-navigation-guard.spec.ts | 26 +- .../app/e2e/browser/plugin-buttons.spec.ts | 12 +- .../browser/replica-cache-measurement.spec.ts | 50 -- .../e2e/browser/settings-host-page.spec.ts | 107 ++-- .../e2e/browser/settings-navigation.spec.ts | 98 +--- .../e2e/browser/startup-wire-metrics.spec.ts | 543 ------------------ .../user-message-contract.ui-contract.spec.ts | 63 -- .../workspace-navigation-regression.spec.ts | 3 + packages/app/e2e/support/creation-fixtures.ts | 7 +- .../support/helpers/agent-timeline-gate.ts | 76 --- packages/app/e2e/support/helpers/composer.ts | 4 +- .../app/e2e/support/helpers/plugin-buttons.ts | 110 ++-- .../helpers/replica-cache-measurement.ts | 312 ---------- .../support/helpers/timeline-pagination.ts | 82 --- .../e2e/keyboard-shortcut-unassign.spec.ts | 66 +-- packages/desktop/e2e/updates.spec.ts | 146 +---- .../providers/opencode-server-manager.test.ts | 73 +-- packages/server/src/server/bootstrap.ts | 2 +- .../src/server/hub/hub-cli-contract.test.ts | 14 +- .../hub/relationship-controller.test.ts | 30 +- .../hub/test-utils/relationship-harness.ts | 68 ++- ...rkspace-reconciliation-observation.test.ts | 78 ++- .../workspace-reconciliation-service.ts | 19 +- 34 files changed, 514 insertions(+), 2305 deletions(-) delete mode 100644 packages/app/e2e/browser/command-center-host.spec.ts delete mode 100644 packages/app/e2e/browser/replica-cache-measurement.spec.ts delete mode 100644 packages/app/e2e/browser/startup-wire-metrics.spec.ts delete mode 100644 packages/app/e2e/browser/user-message-contract.ui-contract.spec.ts delete mode 100644 packages/app/e2e/support/helpers/replica-cache-measurement.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebe2981bbf..5f0f68110d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -358,6 +358,9 @@ jobs: run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + # Weight file groups by observed duration; raw test counts overload + # early agent/Changes specs. Required check names remain unchanged. + PWTEST_SHARD_WEIGHTS: "111:170:137:160" - name: Upload test artifacts uses: actions/upload-artifact@v4 diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 194fe43d1d..5cec1abc56 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -868,7 +868,7 @@ test.describe("Agent message submission", () => { } }); - test("keeps one canonical prompt when the provider echoes before accepting", async ({ + test("keeps canonical prompts through early echoes, reload, and later turns", async ({ page, }, testInfo) => { const workspace = await seedWorkspace({ @@ -889,6 +889,20 @@ test.describe("Agent message submission", () => { await expect(submittedPrompt).toHaveCount(1); await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); await expectVisibleAgentSurfacesIdle(page); + for (const [index, nextPrompt] of [ + "emit 1 coalesced agent stream updates for the second turn.", + "emit 1 coalesced agent stream updates for the third turn.", + ].entries()) { + await submitMessage(page, nextPrompt); + await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible(); + await expectVisibleAgentSurfacesIdle(page); + await expectComposerEditable(page); + await expect(page.getByTestId("user-message")).toHaveCount(index + 2); + } + await fillComposerDraft(page, "Keep this unsent draft."); + await composerLocator(page).blur(); + await expect(page.getByTestId("user-message")).toHaveCount(3); + await expectComposerEditable(page); } finally { await workspace.cleanup(); } @@ -908,16 +922,12 @@ test.describe("Agent message submission", () => { await submitMessage(page, prompt); const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); - const marker = `late-provider-identity-${Date.now()}`; - await submittedPrompt.evaluate((element, value) => { - element.dataset.lateProviderIdentity = value; - }, marker); await submittedPrompt.hover(); await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({ timeout: 5_000, }); - await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible(); + await expect(submittedPrompt).toHaveCount(1); await page.getByRole("button", { name: "Stop agent", exact: true }).click(); await expectVisibleAgentSurfacesIdle(page); } finally { diff --git a/packages/app/e2e/browser/agent-stream-ui.spec.ts b/packages/app/e2e/browser/agent-stream-ui.spec.ts index 2c81fb3308..5ad8a643ee 100644 --- a/packages/app/e2e/browser/agent-stream-ui.spec.ts +++ b/packages/app/e2e/browser/agent-stream-ui.spec.ts @@ -63,91 +63,6 @@ test.describe("Agent stream UI", () => { } }); - test("keeps the active Markdown root mounted across streamed text updates", async ({ - page, - }, testInfo) => { - test.setTimeout(120_000); - const agent = await startRunningMockAgent(page, { - prefix: "stream-markdown-root-", - model: "one-minute-stream", - prompt: "Stream for Markdown root stability test.", - }); - try { - const assistantMessage = page.getByTestId("assistant-message").last(); - await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 }); - - const activeBlock = assistantMessage.locator(":scope > *").last(); - const initialText = (await activeBlock.textContent()) ?? ""; - const activeBlockHandle = await activeBlock.elementHandle(); - if (!activeBlockHandle) { - throw new Error("Expected the active assistant message to contain a block"); - } - const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle(); - if (!markdownRoot) { - throw new Error("Expected the active assistant block to contain a Markdown root"); - } - - await page.evaluate((block) => { - const evidence = { - addedNodes: 0, - characterDataMutations: 0, - removedNodes: 0, - }; - const observer = new MutationObserver((records) => { - for (const record of records) { - evidence.addedNodes += record.addedNodes.length; - evidence.removedNodes += record.removedNodes.length; - if (record.type === "characterData") { - evidence.characterDataMutations += 1; - } - } - }); - observer.observe(block, { characterData: true, childList: true, subtree: true }); - Object.assign(window, { - __markdownRootEvidence: evidence, - __markdownRootObserver: observer, - }); - }, activeBlockHandle); - - await expect - .poll(async () => ((await activeBlock.textContent()) ?? "").length) - .toBeGreaterThan(initialText.length + 80); - - const evidence = await page.evaluate((root) => { - const state = window as typeof window & { - __markdownRootEvidence?: { - addedNodes: number; - characterDataMutations: number; - removedNodes: number; - }; - __markdownRootObserver?: MutationObserver; - }; - state.__markdownRootObserver?.disconnect(); - const messages = document.querySelectorAll('[data-testid="assistant-message"]'); - const message = messages.item(messages.length - 1); - const block = message?.lastElementChild; - return { - ...state.__markdownRootEvidence, - connected: root.isConnected, - sameRoot: block?.firstElementChild === root, - }; - }, markdownRoot); - - await testInfo.attach("markdown-root-stability", { - body: JSON.stringify(evidence, null, 2), - contentType: "application/json", - }); - expect(evidence.connected).toBe(true); - expect(evidence.sameRoot).toBe(true); - expect( - evidence.removedNodes, - `Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`, - ).toBe(0); - } finally { - await agent.cleanup(); - } - }); - test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => { test.setTimeout(120_000); const agent = await seedMockAgentWorkspace({ diff --git a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts index 980d3e3ab7..ec7cedba61 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from "../support/fixtures"; import { - expectSameOlderHistoryLoadingOperation, expectStableHistoryStartGutter, expectTimelineAtHistoryStart, expectTimelinePromptCentered, @@ -12,7 +11,6 @@ import { holdOlderHistoryPages, makeLoadedTimelineFitViewport, openAgentTimeline, - rememberOlderHistoryLoadingOperation, rememberTimelineViewport, rememberTimelinePromptPosition, reloadAgentTimelineFromPersistedReplica, @@ -26,42 +24,10 @@ import { expectTimelineViewportAnchoredAfterPrepend, userNavigatesTimelineToHistoryStartWithKeyboard, userScrollsTimelineToHistoryStart, - waitForPersistedCanonicalTimelineRange, } from "../support/helpers/timeline-pagination"; -import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate"; -import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; test.describe("Agent timeline pagination", () => { - test("resumes a persisted canonical timeline after its exact end", async ({ page }) => { - const prompt = "timeline persisted range resumes without tail replay"; - const agent = await seedMockAgentWorkspace({ - repoPrefix: "timeline-persisted-range-", - title: "Persisted timeline range", - initialPrompt: prompt, - }); - try { - await agent.client.waitForFinish(agent.agentId, 15_000); - await openAgentTimeline(page, agent); - await expectTimelinePromptVisible(page, prompt); - const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId); - - const tracker = await trackAgentTimelineRequests(page, agent.agentId); - await page.reload(); - await expectTimelinePromptVisible(page, prompt); - - const request = await tracker.nextRequest(); - expect(request).toEqual({ - direction: "after", - cursor: { epoch: range.epoch, seq: range.endSeq }, - }); - await tracker.waitForResponse(); - expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false); - } finally { - await agent.cleanup(); - } - }); - - test("keeps a fixed history-start gutter before, during, and after pagination", async ({ + test("keeps the history-start gutter and visible position stable through the final page", async ({ page, }) => { const agent = await seedLongMockAgentTimeline({ turns: 40 }); @@ -73,8 +39,10 @@ test.describe("Agent timeline pagination", () => { await userScrollsTimelineToHistoryStart(page); await history.expectRequestedPages(1); await expectStableHistoryStartGutter(page); + const viewport = await rememberTimelineViewport(page); history.releasePage(1); + await expectTimelineViewportAnchoredAfterPrepend(page, viewport); await history.expectSettledWithRequestedPages(1); await expectStableHistoryStartGutter(page); } finally { @@ -96,7 +64,9 @@ test.describe("Agent timeline pagination", () => { await userScrollsTimelineToHistoryStart(page); await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt); await history.expectRequestedPages(1); + const viewport = await rememberTimelineViewport(page); history.releasePage(1); + await expectTimelineViewportAnchoredAfterPrepend(page, viewport); await history.expectSettledWithRequestedPages(1); await userScrollsTimelineToHistoryStart(page); @@ -137,23 +107,6 @@ test.describe("Agent timeline pagination", () => { } }); - test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => { - test.setTimeout(120_000); - const agent = await seedLongMockAgentTimeline({ turns: 80 }); - try { - const history = await holdOlderHistoryPages(page, agent); - await openAgentTimeline(page, agent); - await userScrollsTimelineToHistoryStart(page); - await history.expectRequestedPages(1); - const viewport = await rememberTimelineViewport(page); - - history.releasePage(1); - await expectTimelineViewportAnchoredAfterPrepend(page, viewport); - } finally { - await agent.cleanup(); - } - }); - test("keeps visible history anchored when live output grows during a prepend", async ({ page, }) => { @@ -195,6 +148,8 @@ test.describe("Agent timeline pagination", () => { ); history.releasePage(1); + await userScrollsTimelineToHistoryStart(page); + await expectTimelinePromptVisible(page, agent.oldestPrompt); await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 }); const running = await agent.client.fetchAgents({ scope: "active" }); expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe( @@ -205,28 +160,7 @@ test.describe("Agent timeline pagination", () => { } }); - test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => { - test.setTimeout(120_000); - const agent = await seedLongMockAgentTimeline({ turns: 40 }); - try { - const history = await holdOlderHistoryPages(page, agent); - await openAgentTimeline(page, agent); - await userScrollsTimelineToHistoryStart(page); - await history.expectRequestedPages(1); - const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt); - - history.releasePage(1); - - await expectTimelinePromptPositionPreserved(page, position); - await history.expectSettledWithRequestedPages(1); - } finally { - await agent.cleanup(); - } - }); - - test("continues one loading operation while older pages still leave history start exposed", async ({ - page, - }) => { + test("keeps loading while older pages still leave history start exposed", async ({ page }) => { test.setTimeout(120_000); const agent = await seedLongMockAgentTimeline({ turns: 80 }); try { @@ -235,12 +169,12 @@ test.describe("Agent timeline pagination", () => { await openAgentTimeline(page, agent); await scrollTimelineToOldestLoadedEdge(page); await history.expectRequestedPages(1); - const loading = await rememberOlderHistoryLoadingOperation(page); + await expect(page.getByTestId("load-older-history-spinner")).toBeVisible(); history.releasePage(1); await history.expectRequestedPages(2); await expectTimelineAtHistoryStart(page); - await expectSameOlderHistoryLoadingOperation(page, loading); + await expect(page.getByTestId("load-older-history-spinner")).toBeVisible(); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 6b66cda768..0b3013d9cc 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -977,63 +977,31 @@ test("changes diff applies code size changes to gutter and code typography", asy expect(after.canvasPixels).not.toEqual(before.canvasPixels); }); -test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await holdBrowserFontLoads(page); - await useUnwrappedDiffLines(page); - await page.setViewportSize({ width: 1400, height: 900 }); - await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); - await waitForWorkspaceTabsVisible(page); - await openChangesPanel(page); - - await expect(page.getByTestId("git-diff-canvas")).toBeVisible(); - await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0); - await releaseBrowserFontLoads(page); - await expectExpandedMountedTabDiff(page); -}); - -test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({ +test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); - - await startReviewOnFirstChangedLine(page); - await cancelInlineReview(page); - await startReviewOnFirstChangedLine(page); - await saveInlineReview(page, "Please keep this branch explicit"); - await editInlineReview(page, "Please keep this branch named explicitly"); - await deleteInlineReview(page); - - await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0); -}); - -test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }); const focusedBackground = await changesTab.evaluate( (element) => getComputedStyle(element).backgroundColor, ); - await startReviewOnFirstChangedLine(page); - await expect(page.getByTestId("inline-review-editor-input")).toBeFocused(); - await expect - .poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor)) - .toBe(focusedBackground); -}); - -test("inline reviews keep the browser text context menu", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - await startReviewOnFirstChangedLine(page); - await page.getByTestId("inline-review-editor-input").click({ button: "right" }); - await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0); + await test.step("start a focused review with the browser text menu", async () => { + await startReviewOnFirstChangedLine(page); + await expect(page.getByTestId("inline-review-editor-input")).toBeFocused(); + await expect(changesTab).toHaveCSS("background-color", focusedBackground); + await page.getByTestId("inline-review-editor-input").click({ button: "right" }); + await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0); + await cancelInlineReview(page); + }); + await test.step("save, edit, and delete the review", async () => { + await startReviewOnFirstChangedLine(page); + await saveInlineReview(page, "Please keep this branch explicit"); + await editInlineReview(page, "Please keep this branch named explicitly"); + await deleteInlineReview(page); + }); }); test("split canvas creates a review on the changed side and keeps it in that column", async ({ @@ -1143,7 +1111,7 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text"); }); -test("canvas diff copies a dragged character selection without opening a review", async ({ +test("selects, copies, and dismisses diff text without opening a review", async ({ context, page, }) => { @@ -1152,27 +1120,32 @@ test("canvas diff copies a dragged character selection without opening a review" await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); - await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); - await page.keyboard.press("ControlOrMeta+C"); - - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH"); - await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); -}); - -test("canvas diff context menu copies selections and source lines", async ({ context, page }) => { - const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ"); - await context.grantPermissions(["clipboard-read", "clipboard-write"]); - await useUnwrappedDiffLines(page); - await openSelectionWorkspaceChanges(page, workspace); - - await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); - await rightClickFirstChangedLine(page); - await page.getByTestId("diff-source-copy-selection").click(); - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH"); - - await rightClickFirstChangedLine(page); - await page.getByTestId("diff-source-copy-line").click(); - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ"); + const readClipboard = () => page.evaluate(() => navigator.clipboard.readText()); + await test.step("copy the selected characters with the keyboard", async () => { + await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); + await page.keyboard.press("ControlOrMeta+C"); + await expect.poll(readClipboard).toBe("CDEFGH"); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); + }); + await test.step("copy the selection and the whole line from the context menu", async () => { + await page.evaluate(() => navigator.clipboard.writeText("")); + await rightClickFirstChangedLine(page); + await page.getByTestId("diff-source-copy-selection").click(); + await expect.poll(readClipboard).toBe("CDEFGH"); + await rightClickFirstChangedLine(page); + await page.getByTestId("diff-source-copy-line").click(); + await expect.poll(readClipboard).toBe("ABCDEFGHIJ"); + }); + await test.step("dismiss a new selection without starting a review", async () => { + await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); + await clickFirstChangedLine(page); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); + await rightClickFirstChangedLine(page); + await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled(); + await page.keyboard.press("Escape"); + await clickFirstChangedLine(page); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); + }); }); test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => { @@ -1186,19 +1159,6 @@ test("canvas diff clears a selection when collapsing an earlier file", async ({ await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled(); }); -test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => { - const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ"); - await useUnwrappedDiffLines(page); - await openSelectionWorkspaceChanges(page, workspace); - - await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); - await clickFirstChangedLine(page); - await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); - - await clickFirstChangedLine(page); - await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); -}); - test("canvas diff replaces a selection with forward and backward drags", async ({ context, page, @@ -1393,37 +1353,6 @@ async function setOpenChangesPresentation( } } -async function holdBrowserFontLoads(page: Page): Promise { - await page.addInitScript(() => { - const fontSet = document.fonts; - const originalLoad = fontSet.load.bind(fontSet); - const pending: Array<() => void> = []; - Object.defineProperty(fontSet, "load", { - configurable: true, - value(font: string, text?: string) { - return new Promise((resolve, reject) => { - pending.push(() => { - originalLoad(font, text).then(resolve, reject); - }); - }); - }, - }); - Object.assign(window, { - __releasePaseoDiffFontLoads() { - for (const release of pending.splice(0)) release(); - }, - }); - }); -} - -async function releaseBrowserFontLoads(page: Page): Promise { - await page.evaluate(() => { - ( - window as typeof window & { __releasePaseoDiffFontLoads: () => void } - ).__releasePaseoDiffFontLoads(); - }); -} - async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise { const canvas = page.getByTestId("git-diff-canvas"); await expect diff --git a/packages/app/e2e/browser/command-center-host.spec.ts b/packages/app/e2e/browser/command-center-host.spec.ts deleted file mode 100644 index be4dd9e830..0000000000 --- a/packages/app/e2e/browser/command-center-host.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; -import { createIdleAgent } from "../support/helpers/archive-tab"; -import { openCommandCenter } from "../support/helpers/command-center"; -import { expectAgentTabActive } from "../support/helpers/launcher"; -import { seedWorkspace } from "../support/helpers/seed-client"; - -test.describe("Command center host labels", () => { - test.describe.configure({ timeout: 180_000 }); - - test("selecting an agent result opens its workspace tab", async ({ page }) => { - const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" }); - const title = `cc-navigation-${randomUUID().slice(0, 8)}`; - - try { - const agent = await createIdleAgent(seeded.client, { - cwd: seeded.repoPath, - workspaceId: seeded.workspaceId, - title, - }); - - await gotoAppShell(page); - const panel = await openCommandCenter(page); - await panel.getByTestId("command-center-input").fill(title); - await page.keyboard.press("Enter"); - - await expectAgentTabActive(page, agent.id); - } finally { - await seeded.cleanup(); - } - }); -}); diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts index 44892a4994..4b01b0c67d 100644 --- a/packages/app/e2e/browser/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -3,6 +3,7 @@ import { expect } from "@playwright/test"; import { test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { createIdleAgent } from "../support/helpers/archive-tab"; +import { expectAgentTabActive } from "../support/helpers/launcher"; import { openCommandCenter } from "../support/helpers/command-center"; import { addOfflineHostAndReload } from "../support/helpers/hosts"; import { expectAppRoute } from "../support/helpers/route-assertions"; @@ -109,6 +110,13 @@ test.describe("Command center workspaces", () => { await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), { timeout: 30_000, }); + + await test.step("find an agent and open its workspace tab", async () => { + const agentPanel = await openCommandCenter(page); + await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE); + await page.keyboard.press("Enter"); + await expectAgentTabActive(page, agent.id); + }); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/browser/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts index acf1941c23..ce8ca72313 100644 --- a/packages/app/e2e/browser/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -52,19 +52,6 @@ const TEST_JSON = { }; test.describe("Composer attachments", () => { - test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => { - test.setTimeout(60_000); - const workspace = await withWorkspace({ prefix: "attach-plus-" }); - await workspace.navigateTo(); - await clickNewChat(page); - await expectComposerVisible(page); - - await openAttachmentMenu(page); - - await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible(); - await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible(); - }); - test("compact Plus menu aligns attachment rows with its sheet title", async ({ page, withWorkspace, @@ -101,59 +88,27 @@ test.describe("Composer attachments", () => { ).toBeVisible({ timeout: 10_000 }); }); - test("GitHub issue attachment pill visible after search and selection", async ({ page }) => { + test("attaches an issue and a pull request from the repository picker", async ({ page }) => { test.setTimeout(120_000); if (!hasGithubAuth()) { test.skip(true, "GitHub auth not available in this environment"); } - const ghRepo = await createTempGithubRepo({ - category: "attach-issue", - issues: [{ title: "fix: attach-issue-unique-alpha" }], - prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }], + category: "attachments", + issues: [{ title: "fix: attachment issue" }], + prs: [{ title: "feat: attachment pull request", state: "open" }], }); const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath); try { await clickNewChat(page); await expectComposerVisible(page); - - await selectGithubOption( - page, - "attach-issue-unique-alpha", - `issue:${ghRepo.issues[0].number}`, - ); - - await expectGithubAttachmentPill(page, { - number: ghRepo.issues[0].number, - title: ghRepo.issues[0].title, - }); - } finally { - await handle.cleanup(); - await ghRepo.cleanup(); - } - }); - - test("GitHub PR attachment pill visible after search and selection", async ({ page }) => { - test.setTimeout(120_000); - if (!hasGithubAuth()) { - test.skip(true, "GitHub auth not available in this environment"); - } - - const ghRepo = await createTempGithubRepo({ - category: "attach-pr", - prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }], - }); - const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath); - try { - await clickNewChat(page); - await expectComposerVisible(page); - - await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`); - - await expectGithubAttachmentPill(page, { - number: ghRepo.prs[0].number, - title: ghRepo.prs[0].title, - }); + // Choose from the repository list. Newly created repositories are not + // necessarily available in GitHub's separate text-search index yet. + await selectGithubOption(page, "", `issue:${ghRepo.issues[0].number}`); + await expectGithubAttachmentPill(page, ghRepo.issues[0]); + await selectGithubOption(page, "", `change_request:${ghRepo.prs[0].number}`); + await expectGithubAttachmentPill(page, ghRepo.prs[0]); + await expectGithubAttachmentPill(page, ghRepo.issues[0]); } finally { await handle.cleanup(); await ghRepo.cleanup(); @@ -168,7 +123,7 @@ test.describe("Composer attachments", () => { // needed before this can be exercised end-to-end. }); - test("image lightbox opens on pill click and closes on Escape", async ({ + test("attaches, previews, and removes an image before dropping a file", async ({ page, withWorkspace, }) => { @@ -178,38 +133,25 @@ test.describe("Composer attachments", () => { await clickNewChat(page); await expectComposerVisible(page); + await test.step("open the available attachment choices", async () => { + await openAttachmentMenu(page); + await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible(); + await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible(); + await page.keyboard.press("Escape"); + }); + await attachImageFromMenu(page, TEST_IMAGE); await expectAttachmentPill(page, "composer-image-attachment-pill"); await openImageLightbox(page); await closeImageLightbox(page); - }); - test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => { - test.setTimeout(60_000); - const workspace = await withWorkspace({ prefix: "attach-pill-" }); - await workspace.navigateTo(); - await clickNewChat(page); - await expectComposerVisible(page); - - await attachImageFromMenu(page, TEST_IMAGE); - - await expectAttachmentPill(page, "composer-image-attachment-pill"); - }); - - test("dropped JSON file renders as a file attachment in active chat", async ({ - page, - withWorkspace, - }) => { - test.setTimeout(60_000); - const workspace = await withWorkspace({ prefix: "attach-drop-json-" }); - await workspace.navigateTo(); - await clickNewChat(page); - await expectComposerVisible(page); - - await dropFileOnComposer(page, TEST_JSON); - - await expectAttachmentPill(page, "composer-file-attachment-pill"); + await test.step("remove the image and attach a dropped file", async () => { + await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment"); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await dropFileOnComposer(page, TEST_JSON); + await expectAttachmentPill(page, "composer-file-attachment-pill"); + }); }); test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => { @@ -238,23 +180,6 @@ test.describe("Composer attachments", () => { } }); - test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => { - test.setTimeout(60_000); - const workspace = await withWorkspace({ prefix: "attach-remove-" }); - await workspace.navigateTo(); - await clickNewChat(page); - await expectComposerVisible(page); - - await attachImageFromMenu(page, TEST_IMAGE); - await expectAttachmentPill(page, "composer-image-attachment-pill"); - - await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment"); - - await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, { - timeout: 5_000, - }); - }); - test("submitting while agent is running queues the message and clears the draft", async ({ page, }) => { diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index 3d19f77cf2..bbc73b797e 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -111,21 +111,6 @@ test.afterEach(async () => { await workspace?.cleanup(); }); -test("file explorer rows share the workspace title opacity treatment", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - await openFileExplorer(page); - - const row = page - .getByTestId("file-explorer-tree-scroll") - .getByTestId(/^file-explorer-row-\d+$/) - .first(); - const name = row.getByTestId(/-name$/); - await expect(row).toHaveCSS("opacity", "1"); - await expect(name).toHaveCSS("opacity", "0.76"); - await row.hover(); - await expect(name).toHaveCSS("opacity", "1"); -}); - test("creates, renames, copies, and deletes entries through the file explorer", async ({ context, page, diff --git a/packages/app/e2e/browser/host-appearance.spec.ts b/packages/app/e2e/browser/host-appearance.spec.ts index aed83103e9..cb6695c439 100644 --- a/packages/app/e2e/browser/host-appearance.spec.ts +++ b/packages/app/e2e/browser/host-appearance.spec.ts @@ -82,121 +82,66 @@ const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({ }, }); -test.describe("Host appearance", () => { - test.describe.configure({ timeout: 180_000 }); +test.describe.configure({ timeout: 180_000 }); - test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => { - const badge = { - serverId: twoHostSidebar.secondaryServerId, - workspaceId: twoHostSidebar.secondaryWorkspaceId, - }; +test("customizes a host badge and preserves its appearance after reload", async ({ + page, + twoHostSidebar, +}) => { + const badge = { + serverId: twoHostSidebar.secondaryServerId, + workspaceId: twoHostSidebar.secondaryWorkspaceId, + }; + const hostName = "Developer MacBook Pro.local"; + + await test.step("rename the host and use its available sidebar width", async () => { await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL }); - - await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); - await renameHostFromSettings(page, "Build Box"); - await leaveHostAppearanceSettings(page); - - await expectHostBadgeName(page, { ...badge, hostName: "Build Box" }); - }); - - test("host names use the available metadata width", async ({ page, twoHostSidebar }) => { - const hostName = "Developer MacBook Pro.local"; - const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`; - - await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await openHostAppearanceSettings(page, badge.serverId); await renameHostFromSettings(page, hostName); await leaveHostAppearanceSettings(page); + await expectHostBadgeName(page, { ...badge, hostName }); - const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`); - const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`); - await expect(badge).toHaveText(hostName); - const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]); - + const row = page.getByTestId(`sidebar-workspace-row-${badge.serverId}:${badge.workspaceId}`); + const hostBadge = row.getByTestId(`host-badge-${badge.serverId}`); + const [badgeBox, rowBox] = await Promise.all([hostBadge.boundingBox(), row.boundingBox()]); expect(badgeBox).not.toBeNull(); expect(rowBox).not.toBeNull(); expect(badgeBox!.width).toBeGreaterThan(96); expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width); - const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({ - clientWidth: label.clientWidth, - scrollWidth: label.scrollWidth, + const widths = await hostBadge.getByText(hostName, { exact: true }).evaluate((label) => ({ + client: label.clientWidth, + content: label.scrollWidth, })); - expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth); + expect(widths.content).toBeLessThanOrEqual(widths.client); }); - test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => { - const badge = { - serverId: twoHostSidebar.secondaryServerId, - workspaceId: twoHostSidebar.secondaryWorkspaceId, - hostName: SECONDARY_HOST_LABEL, - }; - - await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await test.step("preview a color and apply it to the sidebar badge", async () => { + await openHostAppearanceSettings(page, badge.serverId); await chooseHostColor(page, "Teal"); + await expectHostAppearancePreview(page, { serverId: badge.serverId, hostName, color: "teal" }); await leaveHostAppearanceSettings(page); - - await expectHostBadgeTinted(page, { ...badge, color: "teal" }); + await expectHostBadgeTinted(page, { ...badge, hostName, color: "teal" }); }); - test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => { - const badge = { - serverId: twoHostSidebar.secondaryServerId, - workspaceId: twoHostSidebar.secondaryWorkspaceId, - hostName: SECONDARY_HOST_LABEL, - }; - - await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await test.step("keep the icon-only preference through reload", async () => { + await openHostAppearanceSettings(page, badge.serverId); await chooseHostBadgeDisplay(page, "Icon only"); await leaveHostAppearanceSettings(page); - - await expectHostBadgeIconOnly(page, badge); + await expectHostBadgeIconOnly(page, { ...badge, hostName }); + await reloadPreservingHostRegistry(page); + await waitForSidebarHydration(page); + await expectHostBadgeIconOnly(page, { ...badge, hostName }); }); - test("hiding one host leaves the other host's badge visible", async ({ - page, - twoHostSidebar, - }) => { - await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); + await test.step("hide this badge without hiding the other host", async () => { + await openHostAppearanceSettings(page, badge.serverId); await chooseHostBadgeDisplay(page, "Hidden"); await leaveHostAppearanceSettings(page); - - await expectNoHostBadge(page, { - serverId: twoHostSidebar.secondaryServerId, - workspaceId: twoHostSidebar.secondaryWorkspaceId, - hostName: SECONDARY_HOST_LABEL, - }); + await expectNoHostBadge(page, { ...badge, hostName }); await expectHostBadgeName(page, { serverId: twoHostSidebar.primaryServerId, workspaceId: twoHostSidebar.primaryWorkspaceId, hostName: PRIMARY_HOST_LABEL, }); }); - - test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => { - await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); - await renameHostFromSettings(page, "Build Box"); - await chooseHostColor(page, "Amber"); - await chooseHostBadgeDisplay(page, "Icon only"); - await leaveHostAppearanceSettings(page); - - await reloadPreservingHostRegistry(page); - await waitForSidebarHydration(page); - - await expectHostBadgeIconOnly(page, { - serverId: twoHostSidebar.secondaryServerId, - workspaceId: twoHostSidebar.secondaryWorkspaceId, - hostName: "Build Box", - }); - }); - - test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => { - await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); - await renameHostFromSettings(page, "Build Box"); - await chooseHostColor(page, "Emerald"); - - await expectHostAppearancePreview(page, { - serverId: twoHostSidebar.secondaryServerId, - hostName: "Build Box", - color: "emerald", - }); - }); }); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index 9e2dc9de57..699f1fce40 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -13,17 +13,13 @@ import { pressNewTabShortcut, pressDirectNewTabShortcut, getTabTestIds, - waitForTabWithTitle, measureTileTransition, sampleTabsDuringTransition, expectTabTitleFits, terminalSurfaceLocator, } from "../support/helpers/launcher"; import { expectComposerVisible, composerLocator } from "../support/helpers/composer"; -import { - expectTerminalSurfaceVisible, - setupDeterministicPrompt, -} from "../support/helpers/terminal-perf"; +import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { expectTerminalOutputContains, @@ -43,7 +39,7 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = { id: "e2e-empty-prompt", name: "Empty Prompt", command: "/bin/sh", - args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"], + args: ["-c", 'echo prompt-args: "$#"; exec cat', "profile-name", "{{{prompt}}}"], }; async function tabTestIds(tabs: Locator): Promise<(string | null)[]> { @@ -126,47 +122,40 @@ test.describe("Tab creation", () => { await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore); }); - test("Cmd+T creates a New tab without creating an agent", async ({ page }) => { + test("opens the menu, then creates independent New tabs without creating agents", async ({ + page, + }) => { await gotoWorkspace(page, workspace.workspaceId); - const countBefore = await countTabsOfKind(page, "draft"); - - await openNewTabMenuWithShortcut(page); - - await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore); - await expect( - page.getByTestId("workspace-new-tab-panel").filter({ visible: true }), - ).toBeVisible(); - }); - - test("clicking + opens its menu without creating a New tab", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - const countBefore = await countTabsOfKind(page, "new_tab"); - - await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click(); - - await expect( - page.getByTestId("workspace-new-tab-menu").filter({ visible: true }), - ).toBeVisible(); - await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore); - }); - - test("opening two New tabs creates two independent tab identities", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - + await pressNewTabShortcut(page); const newTabs = page .locator('[data-testid^="workspace-tab-tab_"]') .filter({ hasText: "New tab" }); + await expect(newTabs.first()).toBeVisible(); const countBefore = await newTabs.count(); + const draftCount = await countTabsOfKind(page, "draft"); - await pressNewTabShortcut(page); - await expect(newTabs).toHaveCount(countBefore + 1); - const firstIds = await tabTestIds(newTabs); - - await pressNewTabShortcut(page); - await expect(newTabs).toHaveCount(countBefore + 2); - const ids = await tabTestIds(newTabs); - expect(new Set(ids).size).toBe(ids.length); - expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1); + await test.step("opening the plus menu leaves the current tabs intact", async () => { + await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click(); + await expect( + page.getByTestId("workspace-new-tab-menu").filter({ visible: true }), + ).toBeVisible(); + await expect(newTabs).toHaveCount(countBefore); + await page.keyboard.press("Escape"); + }); + await test.step("two shortcuts open two independent launchers", async () => { + await pressNewTabShortcut(page); + await expect(newTabs).toHaveCount(countBefore + 1); + const firstIds = await tabTestIds(newTabs); + await pressNewTabShortcut(page); + await expect(newTabs).toHaveCount(countBefore + 2); + const ids = await tabTestIds(newTabs); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toEqual(expect.arrayContaining(firstIds)); + expect(await countTabsOfKind(page, "draft")).toBe(draftCount); + await expect( + page.getByTestId("workspace-new-tab-panel").filter({ visible: true }), + ).toBeVisible(); + }); }); test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({ @@ -267,110 +256,6 @@ test.describe("Tab creation", () => { await assertNewChatTileVisible(page); await assertNewTabMenuTriggerVisible(page); }); - - test("never shades the New tab button", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - - const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(); - const scrollArea = tabRow.getByTestId("workspace-tabs-scroll"); - const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count(); - const scrollShades = await tabRow - .locator('[data-testid^="workspace-tabs-scroll-shade-"]') - .count(); - - expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true); - await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Terminal Title Tests -// ═══════════════════════════════════════════════════════════════════════════ - -test.describe("Terminal title propagation", () => { - // OSC title escape sequence propagation is inherently flaky — the terminal - // must process the sequence, emit a title change event, and the tab bar - // must re-render before the assertion deadline. Allow retries. - test.describe.configure({ retries: 2 }); - - test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => { - test.setTimeout(60_000); - - const result = await workspace.client.createTerminal( - workspace.repoPath, - "title-test", - undefined, - { - workspaceId: workspace.workspaceId, - }, - ); - if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`); - const terminalId = result.terminal.id; - - try { - // Navigate to workspace and open a terminal - await gotoWorkspace(page, workspace.workspaceId); - await clickNewTerminal(page); - - await expectTerminalSurfaceVisible(page); - await terminalSurfaceLocator(page).click(); - - await setupDeterministicPrompt(page); - - // Send OSC 0 (set window title) escape sequence - const testTitle = `E2E-Title-${Date.now()}`; - await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { - delay: 0, - }); - - // Wait for the tab to reflect the new title - await waitForTabWithTitle(page, testTitle, 15_000); - } finally { - await workspace.client.killTerminal(terminalId).catch(() => {}); - } - }); - - test.skip("title debouncing coalesces rapid changes", async ({ page }) => { - test.setTimeout(60_000); - - const result = await workspace.client.createTerminal( - workspace.repoPath, - "debounce-test", - undefined, - { - workspaceId: workspace.workspaceId, - }, - ); - if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`); - const terminalId = result.terminal.id; - - try { - await gotoWorkspace(page, workspace.workspaceId); - await clickNewTerminal(page); - - await expectTerminalSurfaceVisible(page); - await terminalSurfaceLocator(page).click(); - - await setupDeterministicPrompt(page); - - // Fire many rapid title changes — only the last should stick - const finalTitle = `Final-${Date.now()}`; - for (let i = 0; i < 5; i++) { - await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { - delay: 0, - }); - } - await terminalSurfaceLocator(page).pressSequentially( - `printf '\\033]0;${finalTitle}\\007'\n`, - { delay: 0 }, - ); - - // The tab should eventually settle on the final title - await waitForTabWithTitle(page, finalTitle, 15_000); - } finally { - await workspace.client.killTerminal(terminalId).catch(() => {}); - } - }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts b/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts index 69becc94ab..dde653ab65 100644 --- a/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts +++ b/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts @@ -4,17 +4,21 @@ import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace test.describe("Delayed workspace creation", () => { test.describe.configure({ timeout: 120_000 }); - for (const launch of ["chat", "terminal", "empty"] as const) { - test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => { - await verifyDelayedWorkspaceCreation(page, launch, "leave"); - }); - test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => { - await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree"); - }); - test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => { - await verifyDelayedWorkspaceCreation(page, launch, "stay"); - }); - } + // Exercise each submit handler's navigation guard. Checkout isolation shares + // those handlers; ordinary successful launches live in the creation journeys. + test("chat worktree creation keeps the workspace chosen while it was pending", async ({ + page, + }) => { + await verifyDelayedWorkspaceCreation(page, "chat", "leave", "worktree"); + }); + + test("terminal creation keeps the workspace chosen while it was pending", async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, "terminal", "leave"); + }); + + test("empty creation keeps the workspace chosen while it was pending", async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, "empty", "leave"); + }); test("preserves a newer draft opened while creation is pending", async ({ page }) => { await verifyDelayedWorkspaceCreation(page, "chat", "new-draft"); diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index 21221f859b..22eb61910b 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -3,19 +3,13 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons"; test("plugin header and composer buttons share actions, menus, content, and updates", async ({ page, -}, testInfo) => { +}) => { test.setTimeout(180_000); - await withButtonShowcase(page, testInfo, async (buttons) => { + await withButtonShowcase(page, async (buttons) => { + await buttons.verifyObservationLifetime(); await buttons.openWideMenusAndPopovers(); await buttons.runAndUpdateActions(); await buttons.openCompactSheets(); await buttons.hideAndUnloadOpenButtons(); }); }); - -test("plugin button observations follow mounted content and error lifetimes", async ({ - page, -}, testInfo) => { - test.setTimeout(120_000); - await withButtonShowcase(page, testInfo, (buttons) => buttons.verifyObservationLifetime()); -}); diff --git a/packages/app/e2e/browser/replica-cache-measurement.spec.ts b/packages/app/e2e/browser/replica-cache-measurement.spec.ts deleted file mode 100644 index cc77f2260c..0000000000 --- a/packages/app/e2e/browser/replica-cache-measurement.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { expect, test } from "@playwright/test"; -import { - beginReplicaCacheObservation, - finishReplicaCacheObservation, - installReplicaCacheMeasurement, - measureReplicaCacheRestore, - type ReplicaCacheMeasurementReport, -} from "../support/helpers/replica-cache-measurement"; - -const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1"; -const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL; -const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT; -const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000); -const HYDRATED_SELECTOR = - process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ?? - '[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]'; - -const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip; - -measurementDescribe("Replica cache live measurement", () => { - test("observes persistence and cached restore without mutating the daemon", async ({ - page, - }, testInfo) => { - test.setTimeout(OBSERVATION_MS + 90_000); - expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy(); - expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy(); - - await installReplicaCacheMeasurement(page); - await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle").catch(() => undefined); - - const observation = await beginReplicaCacheObservation(page); - await page.waitForTimeout(OBSERVATION_MS); - const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!); - const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR); - const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs }; - - await mkdir(path.dirname(REPORT_PATH!), { recursive: true }); - await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`); - await testInfo.attach("replica-cache-measurement", { - body: JSON.stringify(report, null, 2), - contentType: "application/json", - }); - console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`); - - expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS); - }); -}); diff --git a/packages/app/e2e/browser/settings-host-page.spec.ts b/packages/app/e2e/browser/settings-host-page.spec.ts index 8a702f8c08..6eec96d384 100644 --- a/packages/app/e2e/browser/settings-host-page.spec.ts +++ b/packages/app/e2e/browser/settings-host-page.spec.ts @@ -21,52 +21,47 @@ import { } from "../support/helpers/settings"; test.describe("Settings host page", () => { - test("connections section shows the seeded connection endpoint", async ({ page }) => { + test("visits host settings and opens the label editor", async ({ page }) => { const serverId = getServerId(); const port = getE2EDaemonPort(); - await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); + await test.step("connections section shows the seeded connection endpoint", async () => { + await expectSettingsHeader(page, "Connections"); + await expectHostConnectionsCard(page, port); + }); + await test.step("agents section shows the inject MCP toggle", async () => { + await openHostSection(page, serverId, "agents"); + await expectSettingsHeader(page, "Agents"); + await expectHostInjectMcpCard(page); + }); + await test.step("providers section shows the providers card", async () => { + await expectHostProvidersCard(page, serverId); + await expectSettingsHeader(page, "Providers"); + }); + await test.step("host section shows the host label and restart/remove action cards", async () => { + await openHostSection(page, serverId, "host"); + await expectSettingsHeader(page, "Overview"); + await expectHostLabelDisplayed(page); + await expectHostActionCards(page, serverId); + }); + await test.step("clicking the label pencil reveals the inline editor", async () => { + await openHostSection(page, serverId, "host"); - await expectSettingsHeader(page, "Connections"); - await expectHostConnectionsCard(page, port); - }); + await expectHostLabelDisplayed(page); + await clickEditHostLabel(page); + await expectHostLabelEditMode(page, TEST_HOST_LABEL); + await page.keyboard.press("Escape"); + }); + await test.step("host section does not render daemon lifecycle controls for a remote daemon", async () => { + await openHostSection(page, serverId, "host"); - test("agents section shows the inject MCP toggle", async ({ page }) => { - const serverId = getServerId(); - - await gotoAppShell(page); - await openSettings(page); - await openSettingsHost(page, serverId); - - await openHostSection(page, serverId, "agents"); - await expectSettingsHeader(page, "Agents"); - await expectHostInjectMcpCard(page); - }); - - test("providers section shows the providers card", async ({ page }) => { - const serverId = getServerId(); - - await gotoAppShell(page); - await openSettings(page); - await openSettingsHost(page, serverId); - - await expectHostProvidersCard(page, serverId); - await expectSettingsHeader(page, "Providers"); - }); - - test("host section shows the host label and restart/remove action cards", async ({ page }) => { - const serverId = getServerId(); - - await gotoAppShell(page); - await openSettings(page); - await openSettingsHost(page, serverId); - - await openHostSection(page, serverId, "host"); - await expectSettingsHeader(page, "Overview"); - await expectHostLabelDisplayed(page); - await expectHostActionCards(page, serverId); + await expectHostNoDaemonLifecycleRow(page); + }); + await test.step("settings sidebar exposes the flat App and Host section rows", async () => { + await expectRetiredSidebarSectionsAbsent(page); + }); }); test("a failed remote daemon update remains visible in the host UI", async ({ @@ -94,40 +89,6 @@ test.describe("Settings host page", () => { await expect(updateButton).toBeEnabled(); }); - test("clicking the label pencil reveals the inline editor", async ({ page }) => { - const serverId = getServerId(); - - await gotoAppShell(page); - await openSettings(page); - await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "host"); - - await expectHostLabelDisplayed(page); - await clickEditHostLabel(page); - await expectHostLabelEditMode(page, TEST_HOST_LABEL); - }); - - test("host section does not render daemon lifecycle controls for a remote daemon", async ({ - page, - }) => { - const serverId = getServerId(); - - await gotoAppShell(page); - await openSettings(page); - await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "host"); - - // TODO: add a local-daemon fixture for positive daemon lifecycle coverage. - await expectHostNoDaemonLifecycleRow(page); - }); - - test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => { - await gotoAppShell(page); - await openSettings(page); - - await expectRetiredSidebarSectionsAbsent(page); - }); - test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({ page, }) => { diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index e13a29535c..bc8e1eedec 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -72,6 +72,9 @@ test.describe("Settings sidebar navigation", () => { await openSettingsSection(page, "appearance"); await expectSettingsHeader(page, "Appearance"); await expectAppearanceContent(page); + + await clickSettingsBackToWorkspace(page); + await expect(page).not.toHaveURL(/\/settings(\/|$)/); }); test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => { @@ -79,19 +82,13 @@ test.describe("Settings sidebar navigation", () => { await verifyLegacyHostSettingsRedirect(page); }); - test("the + Add host button opens the add-host method modal", async ({ page }) => { - await gotoAppShell(page); - await openSettings(page); - await openAddHostFlow(page); - await expectAddHostMethodOptions(page); - }); - test("direct connection advanced URI round-trips SSL and password into the form", async ({ page, }) => { await gotoAppShell(page); await openSettings(page); await openAddHostFlow(page); + await expectAddHostMethodOptions(page); await selectHostConnectionType(page, "direct"); await toggleHostAdvanced(page); @@ -115,20 +112,6 @@ test.describe("Settings sidebar navigation", () => { await expectDirectHostUriHidden(page); }); - test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => { - await gotoAppShell(page); - await openSettings(page); - await clickSettingsBackToWorkspace(page); - await expect(page).not.toHaveURL(/\/settings(\/|$)/); - }); - - test("pressing Escape closes settings", async ({ page }) => { - await gotoAppShell(page); - await openSettings(page); - await page.keyboard.press("Escape"); - await expect(page).not.toHaveURL(/\/settings(\/|$)/); - }); - test("Escape lets settings dropdowns and modals close before leaving settings", async ({ page, }) => { @@ -155,66 +138,45 @@ test.describe("Settings sidebar navigation", () => { await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0); await expect(page).toHaveURL(/\/settings(\/|$)/); }); + + await page.keyboard.press("Escape"); + await expect(page).not.toHaveURL(/\/settings(\/|$)/); }); }); test.describe("Settings — compact master-detail", () => { test.use({ viewport: { width: 390, height: 844 } }); - test("/settings renders only the sidebar list (no section content)", async ({ page }) => { + test("opens compact app and host details and returns through the settings list", async ({ + page, + }) => { await gotoAppShell(page); await openCompactSettings(page, buildOpenProjectRoute()); await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]); await expectCompactSettingsList(page); - await expectSettingsBackButton(page); - await goBackInSettings(page); - await expect(page).not.toHaveURL(/\/settings(\/|$)/); - }); + await test.step("open app details and return to the list", async () => { + await openSettingsSection(page, "diagnostics"); + await expectAppRoute(page, buildSettingsSectionRoute("diagnostics")); + await expectDiagnosticsContent(page); + await expectSettingsSidebarHidden(page); + await expectSettingsBackButton(page); + await goBackInSettings(page); + await expectCompactSettingsList(page); + }); - test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => { - await gotoAppShell(page); - await openCompactSettings(page, buildOpenProjectRoute()); - - await openSettingsSection(page, "diagnostics"); - await expectAppRoute(page, buildSettingsSectionRoute("diagnostics")); - await expectDiagnosticsContent(page); - await expectSettingsSidebarHidden(page); - await expectSettingsBackButton(page); - }); - - test("back from a section detail returns to the /settings list", async ({ page }) => { - await gotoAppShell(page); - await openCompactSettings(page, buildOpenProjectRoute()); - - await openSettingsSection(page, "about"); - await expectAppRoute(page, buildSettingsSectionRoute("about")); - - await goBackInSettings(page); - await expectCompactSettingsList(page); - await expectSettingsBackButton(page); - }); - - test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({ - page, - }) => { - await gotoAppShell(page); - await openCompactSettings(page, buildOpenProjectRoute()); - - await openCompactSettingsHost(page); - await expectSettingsBackButton(page); - await expectSettingsSidebarHidden(page); - }); - - test("back from a host detail returns to the /settings list", async ({ page }) => { - await gotoAppShell(page); - await openCompactSettings(page, buildOpenProjectRoute()); - - await openCompactSettingsHost(page); - await goBackInSettings(page); - await expectAppRoute(page, buildSettingsRoute()); - await expectSettingsSidebarVisible(page); + await test.step("open host details and return through the list", async () => { + await openCompactSettingsHost(page); + await expectSettingsBackButton(page); + await expectSettingsSidebarHidden(page); + await goBackInSettings(page); + await expectAppRoute(page, buildSettingsRoute()); + await expectSettingsSidebarVisible(page); + await expectSettingsBackButton(page); + await goBackInSettings(page); + await expect(page).not.toHaveURL(/\/settings(\/|$)/); + }); }); test("host picker settings opens Overview and backs through the settings list", async ({ diff --git a/packages/app/e2e/browser/startup-wire-metrics.spec.ts b/packages/app/e2e/browser/startup-wire-metrics.spec.ts deleted file mode 100644 index d624d5e316..0000000000 --- a/packages/app/e2e/browser/startup-wire-metrics.spec.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { Buffer } from "node:buffer"; -import type { CDPSession, Page, TestInfo } from "@playwright/test"; -import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; - -type WireDirection = "sent" | "received"; -type WirePhase = "startup" | "workspace_clicks"; - -interface ParsedWireMessage { - type: string | null; - requestId: string | null; - entryCount: number | null; - hasMore: boolean | null; - providerEntries: ProviderSnapshotWireEntry[] | null; -} - -type WireFrameRecord = ParsedWireMessage & { - phase: WirePhase; - direction: WireDirection; - bytes: number; -}; - -interface ProviderSnapshotWireEntry { - provider: string; - status: string | null; - modelCount: number; - modeCount: number; - bytes: number; -} - -interface WebSocketFrameEvent { - requestId: string; - response: { - opcode: number; - payloadData: string; - }; -} - -interface WireSummary { - totalFrames: number; - totalBytes: number; - byDirection: Record; - byPhase: Record< - WirePhase, - { - frames: number; - bytes: number; - byType: Array<{ type: string; frames: number; bytes: number }>; - rpcCounts: Array<{ requestType: string; count: number }>; - rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>; - } - >; - fetchPages: Array<{ - phase: WirePhase; - type: string; - requestId: string | null; - entries: number | null; - hasMore: boolean | null; - bytes: number; - }>; - clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>; - providerSnapshots: Array<{ - phase: WirePhase; - type: string; - requestId: string | null; - totalModels: number; - totalModes: number; - bytes: number; - providers: ProviderSnapshotWireEntry[]; - }>; - providerSnapshotTotals: Array<{ - phase: WirePhase; - provider: string; - frames: number; - bytes: number; - maxModels: number; - maxModes: number; - statuses: string[]; - }>; - fork: { - sourceHome: string | null; - targetHome: string | null; - copiedFiles: number | null; - copiedBytes: number | null; - }; -} - -function extractWorkspaceTestIds(elements: Element[]): string[] { - const result: string[] = []; - for (const element of elements.slice(0, 3)) { - const value = element.getAttribute("data-testid"); - if (value) { - result.push(value); - } - } - return result; -} - -class WireMonitor { - private phase: WirePhase = "startup"; - private session: CDPSession | null = null; - readonly records: WireFrameRecord[] = []; - - async start(page: Page): Promise { - this.session = await page.context().newCDPSession(page); - await this.session.send("Network.enable"); - this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => { - this.record("sent", event); - }); - this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => { - this.record("received", event); - }); - } - - setPhase(phase: WirePhase): void { - this.phase = phase; - } - - hasCompletedStartupFetches(): boolean { - return ( - this.records.some( - (record) => - record.direction === "received" && - record.type === "fetch_agents_response" && - record.hasMore === false, - ) && - this.records.some( - (record) => - record.direction === "received" && - record.type === "fetch_workspaces_response" && - record.hasMore === false, - ) - ); - } - - summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary { - return { - totalFrames: this.records.length, - totalBytes: sumBytes(this.records), - byDirection: { - sent: summarizeDirection(this.records, "sent"), - received: summarizeDirection(this.records, "received"), - }, - byPhase: { - startup: summarizePhase(this.records, "startup"), - workspace_clicks: summarizePhase(this.records, "workspace_clicks"), - }, - fetchPages: this.records - .filter( - (record) => - record.direction === "received" && - (record.type === "fetch_agents_response" || - record.type === "fetch_workspaces_response"), - ) - .map((record) => ({ - phase: record.phase, - type: record.type ?? "unknown", - requestId: record.requestId, - entries: record.entryCount, - hasMore: record.hasMore, - bytes: record.bytes, - })), - clickedWorkspaces, - providerSnapshots: this.records - .filter((record) => record.direction === "received" && record.providerEntries) - .map((record) => ({ - phase: record.phase, - type: record.type ?? "unknown", - requestId: record.requestId, - totalModels: sumProviderModels(record.providerEntries ?? []), - totalModes: sumProviderModes(record.providerEntries ?? []), - bytes: record.bytes, - providers: record.providerEntries ?? [], - })), - providerSnapshotTotals: summarizeProviderSnapshots(this.records), - fork: { - sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null, - targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null, - copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES), - copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES), - }, - }; - } - - private record(direction: WireDirection, event: WebSocketFrameEvent): void { - if (event.response.opcode !== 1) { - this.records.push({ - phase: this.phase, - direction, - bytes: Buffer.byteLength(event.response.payloadData), - type: `opcode:${event.response.opcode}`, - requestId: null, - entryCount: null, - hasMore: null, - providerEntries: null, - }); - return; - } - - this.records.push({ - phase: this.phase, - direction, - bytes: Buffer.byteLength(event.response.payloadData, "utf8"), - ...parseWireMessage(event.response.payloadData), - }); - } -} - -function parseOptionalNumber(value: string | undefined): number | null { - if (!value) { - return null; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function parseWireMessage(payloadData: string): ParsedWireMessage { - try { - const parsed = JSON.parse(payloadData) as unknown; - if (!parsed || typeof parsed !== "object") { - return emptyParsedWireMessage(); - } - const envelope = parsed as { - type?: unknown; - message?: unknown; - }; - const unwrapped = - envelope.type === "session" && envelope.message && typeof envelope.message === "object" - ? envelope.message - : parsed; - const message = unwrapped as { - type?: unknown; - requestId?: unknown; - payload?: { - requestId?: unknown; - entries?: unknown; - agents?: unknown; - pageInfo?: { hasMore?: unknown }; - }; - }; - const entries = readMessageEntries(message.payload); - const hasMore = readMessageHasMore(message.payload); - const messageType = typeof message.type === "string" ? message.type : null; - const providerEntries = isProviderSnapshotMessage(messageType) - ? parseProviderSnapshotEntries(message.payload) - : null; - return { - type: messageType, - requestId: readMessageRequestId(message), - entryCount: entries ? entries.length : null, - hasMore, - providerEntries, - }; - } catch { - return emptyParsedWireMessage(); - } -} - -function readMessageRequestId(message: { - requestId?: unknown; - payload?: { requestId?: unknown }; -}): string | null { - if (typeof message.requestId === "string") { - return message.requestId; - } - if (typeof message.payload?.requestId === "string") { - return message.payload.requestId; - } - return null; -} - -function isProviderSnapshotMessage(messageType: string | null): boolean { - return ( - messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update" - ); -} - -function readMessageEntries( - payload: - | { - entries?: unknown; - agents?: unknown; - } - | undefined, -): unknown[] | null { - if (Array.isArray(payload?.entries)) { - return payload.entries; - } - if (Array.isArray(payload?.agents)) { - return payload.agents; - } - return null; -} - -function readMessageHasMore( - payload: { pageInfo?: { hasMore?: unknown } } | undefined, -): boolean | null { - return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null; -} - -function emptyParsedWireMessage(): ParsedWireMessage { - return { - type: null, - requestId: null, - entryCount: null, - hasMore: null, - providerEntries: null, - }; -} - -function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null { - if (!payload || typeof payload !== "object") { - return null; - } - const entries = (payload as { entries?: unknown }).entries; - if (!Array.isArray(entries)) { - return null; - } - - const providerEntries: ProviderSnapshotWireEntry[] = []; - for (const entry of entries) { - if (!entry || typeof entry !== "object") { - continue; - } - const provider = (entry as { provider?: unknown }).provider; - if (typeof provider !== "string") { - continue; - } - const models = (entry as { models?: unknown }).models; - const modes = (entry as { modes?: unknown }).modes; - const status = (entry as { status?: unknown }).status; - providerEntries.push({ - provider, - status: typeof status === "string" ? status : null, - modelCount: Array.isArray(models) ? models.length : 0, - modeCount: Array.isArray(modes) ? modes.length : 0, - bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"), - }); - } - - return providerEntries.length > 0 ? providerEntries : null; -} - -function sumBytes(records: WireFrameRecord[]): number { - return records.reduce((sum, record) => sum + record.bytes, 0); -} - -function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) { - const selected = records.filter((record) => record.direction === direction); - return { - frames: selected.length, - bytes: sumBytes(selected), - }; -} - -function summarizePhase(records: WireFrameRecord[], phase: WirePhase) { - const selected = records.filter((record) => record.phase === phase); - return { - frames: selected.length, - bytes: sumBytes(selected), - byType: summarizeByType(selected), - rpcCounts: summarizeRpcCounts(selected), - rpcs: summarizeRpcs(selected), - }; -} - -function summarizeByType(records: WireFrameRecord[]): Array<{ - type: string; - frames: number; - bytes: number; -}> { - const byType = new Map(); - for (const record of records) { - const type = record.type ?? "unknown"; - const current = byType.get(type) ?? { frames: 0, bytes: 0 }; - current.frames += 1; - current.bytes += record.bytes; - byType.set(type, current); - } - return [...byType.entries()] - .map(([type, value]) => Object.assign({ type }, value)) - .sort((left, right) => right.bytes - left.bytes); -} - -function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] { - const responsesByRequestId = new Map(); - for (const record of records) { - if (record.direction !== "received" || !record.requestId) { - continue; - } - responsesByRequestId.set(record.requestId, record.type ?? "unknown"); - } - - return records - .filter((record) => record.direction === "sent" && record.requestId && record.type) - .map((record) => ({ - requestType: record.type ?? "unknown", - requestId: record.requestId ?? "", - responseType: responsesByRequestId.get(record.requestId ?? "") ?? null, - })); -} - -function summarizeRpcCounts( - records: WireFrameRecord[], -): Array<{ requestType: string; count: number }> { - const counts = new Map(); - for (const record of records) { - if (record.direction !== "sent" || !record.type || !record.requestId) { - continue; - } - counts.set(record.type, (counts.get(record.type) ?? 0) + 1); - } - return [...counts.entries()] - .map(([requestType, count]) => ({ requestType, count })) - .sort( - (left, right) => - right.count - left.count || left.requestType.localeCompare(right.requestType), - ); -} - -function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number { - return entries.reduce((sum, entry) => sum + entry.modelCount, 0); -} - -function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number { - return entries.reduce((sum, entry) => sum + entry.modeCount, 0); -} - -function summarizeProviderSnapshots( - records: WireFrameRecord[], -): WireSummary["providerSnapshotTotals"] { - const byProvider = new Map< - string, - { - phase: WirePhase; - provider: string; - frames: number; - bytes: number; - maxModels: number; - maxModes: number; - statuses: Set; - } - >(); - - for (const record of records) { - if (record.direction !== "received" || !record.providerEntries) { - continue; - } - for (const entry of record.providerEntries) { - const key = `${record.phase}:${entry.provider}`; - const current = byProvider.get(key) ?? { - phase: record.phase, - provider: entry.provider, - frames: 0, - bytes: 0, - maxModels: 0, - maxModes: 0, - statuses: new Set(), - }; - current.frames += 1; - current.bytes += entry.bytes; - current.maxModels = Math.max(current.maxModels, entry.modelCount); - current.maxModes = Math.max(current.maxModes, entry.modeCount); - if (entry.status) { - current.statuses.add(entry.status); - } - byProvider.set(key, current); - } - } - - return [...byProvider.values()] - .map((entry) => ({ - phase: entry.phase, - provider: entry.provider, - frames: entry.frames, - bytes: entry.bytes, - maxModels: entry.maxModels, - maxModes: entry.maxModes, - statuses: [...entry.statuses].sort(), - })) - .sort((left, right) => right.bytes - left.bytes); -} - -async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise { - await testInfo.attach("startup-wire-metrics.json", { - body: JSON.stringify(summary, null, 2), - contentType: "application/json", - }); -} - -test.describe("ad hoc startup wire metrics", () => { - test.skip( - process.env.E2E_WIRE_METRICS !== "1", - "Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.", - ); - - test("measures startup hydration and workspace navigation websocket traffic", async ({ - page, - }, testInfo) => { - test.setTimeout(180_000); - - const monitor = new WireMonitor(); - await monitor.start(page); - - await gotoAppShell(page); - await waitForSidebarHydration(page, 120_000); - await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true); - await page.waitForTimeout(1_000); - - const workspaceTestIds = await page - .locator('[data-testid^="sidebar-workspace-row-"]:visible') - .evaluateAll(extractWorkspaceTestIds); - - monitor.setPhase("workspace_clicks"); - const clickedWorkspaces: WireSummary["clickedWorkspaces"] = []; - for (const testId of workspaceTestIds) { - const row = page.getByTestId(testId); - if (!(await row.isVisible().catch(() => false))) { - continue; - } - const beforeFrames = monitor.records.length; - const beforeBytes = sumBytes(monitor.records); - await row.click(); - await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); - await page.waitForTimeout(1_000); - clickedWorkspaces.push({ - testId, - frames: monitor.records.length - beforeFrames, - bytes: sumBytes(monitor.records) - beforeBytes, - }); - } - - const summary = monitor.summarize(clickedWorkspaces); - await attachSummary(testInfo, summary); - console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN"); - console.log(JSON.stringify(summary, null, 2)); - console.log("PASEO_STARTUP_WIRE_METRICS_END"); - - expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0); - expect(clickedWorkspaces.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/app/e2e/browser/user-message-contract.ui-contract.spec.ts b/packages/app/e2e/browser/user-message-contract.ui-contract.spec.ts deleted file mode 100644 index 55a4df4ba2..0000000000 --- a/packages/app/e2e/browser/user-message-contract.ui-contract.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, test, type Page } from "../support/fixtures"; -import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { - composerLocator, - expectComposerEditable, - expectComposerVisible, - fillComposerDraft, - submitMessage, -} from "../support/helpers/composer"; - -async function expectUserMessageCount(page: Page, expected: number): Promise { - await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 }); -} - -async function expectIdleComposer(page: Page): Promise { - await expectComposerEditable(page); - await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { - timeout: 15_000, - }); -} - -async function expectNoLoadingRegressionAfterIdle(page: Page): Promise { - await expectIdleComposer(page); - await page.waitForTimeout(1_000); - await expectIdleComposer(page); -} - -test.describe("User message UI contract", () => { - test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => { - const session = await seedMockAgentWorkspace({ - repoPrefix: "user-message-contract-e2e-", - title: "User message contract e2e", - }); - const prompts = [ - "emit 1 coalesced agent stream updates for user message contract turn one.", - "emit 1 coalesced agent stream updates for user message contract turn two.", - "emit 1 coalesced agent stream updates for user message contract turn three.", - ]; - - try { - await openAgentRoute(page, session); - await expectComposerVisible(page); - - for (let index = 0; index < prompts.length; index += 1) { - const prompt = prompts[index]!; - await submitMessage(page, prompt); - await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({ - timeout: 15_000, - }); - await expectUserMessageCount(page, index + 1); - await expectNoLoadingRegressionAfterIdle(page); - } - - await fillComposerDraft(page, "append"); - await composerLocator(page).evaluate((element) => element.blur()); - await expectUserMessageCount(page, 3); - await expectIdleComposer(page); - } finally { - await session.cleanup(); - } - }); -}); diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index 08a4604aec..563d019bd2 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -178,6 +178,9 @@ test.describe("Workspace navigation regression", () => { }); await waitForWorkspaceTabsVisible(page); await expectWorkspaceTabVisible(page, agent.id); + // The reconnect toast belongs to the visible agent panel, which mounts + // after the tab strip. Drop the connection only after that panel is ready. + await expectComposerVisible(page); await daemonGate.drop(); await daemonGate.waitForBlockedConnection(); diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts index 67a9528b05..33308ee386 100644 --- a/packages/app/e2e/support/creation-fixtures.ts +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -1,4 +1,4 @@ -import { writeFile, rm } from "node:fs/promises"; +import { writeFile, rm, rename } from "node:fs/promises"; import path from "node:path"; import { copyPluginExample } from "./helpers/plugin-fixture"; import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; @@ -23,9 +23,11 @@ export const test = base.extend<{ const client = await connectNewWorkspaceDaemonClient(); const plugin = await copyPluginExample("lifecycle-logger"); const gate = path.join(plugin.directory, "startup.txt"); + const failure = path.join(plugin.directory, "startup-failure.txt"); const previous = await client.getDaemonConfig(); try { await writeFile(gate, "hold"); + await writeFile(failure, "fail"); await writeFile( path.join(plugin.directory, "index.server.ts"), ` @@ -52,7 +54,8 @@ export default function contribute(server) { await client.installDirectoryPlugin(plugin.directory); await provide({ release: () => rm(gate, { force: true }), - fail: () => writeFile(gate, "fail"), + // Replacing the command must not expose a truncated, empty file to the hook. + fail: () => rename(failure, gate), }); } finally { await rm(gate, { force: true }); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 7651f32a94..dd50ccfa3d 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -39,82 +39,6 @@ export interface PromptJumpRequestTracker { requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>; } -export interface TimelineRequestTracker { - nextRequest(): Promise<{ - direction: string | null; - cursor: { epoch: string; seq: number } | null; - }>; - requests(): Array<{ - direction: string | null; - cursor: { epoch: string; seq: number } | null; - }>; - waitForResponse(): Promise; -} - -export async function trackAgentTimelineRequests( - page: Page, - agentId: string, -): Promise { - type Request = ReturnType[number]; - const seen: Request[] = []; - const waiters: Array<(request: Request) => void> = []; - let responseSeen = false; - let resolveResponse: (() => void) | null = null; - const response = new Promise((resolve) => { - resolveResponse = resolve; - }); - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if ( - sessionMessage?.type === "fetch_agent_timeline_request" && - sessionMessage.agentId === agentId - ) { - const rawCursor = sessionMessage.cursor; - const cursor = - rawCursor && - typeof rawCursor === "object" && - typeof (rawCursor as { epoch?: unknown }).epoch === "string" && - typeof (rawCursor as { seq?: unknown }).seq === "number" - ? { - epoch: (rawCursor as { epoch: string }).epoch, - seq: (rawCursor as { seq: number }).seq, - } - : null; - const request = { - direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null, - cursor, - }; - seen.push(request); - waiters.shift()?.(request); - } - server.send(message); - }); - server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = sessionMessage ? getPayload(sessionMessage) : null; - if ( - sessionMessage?.type === "fetch_agent_timeline_response" && - payload?.agentId === agentId - ) { - responseSeen = true; - resolveResponse?.(); - } - ws.send(message); - }); - }); - return { - nextRequest() { - const request = seen[0]; - if (request) return Promise.resolve(request); - return new Promise((resolve) => waiters.push(resolve)); - }, - requests: () => [...seen], - waitForResponse: () => (responseSeen ? Promise.resolve() : response), - }; -} - export async function trackPromptJumpRequests( page: Page, agentId: string, diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index c7d2683284..45c5a95c6e 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -166,7 +166,7 @@ export async function expectGithubAttachmentPill( page: Page, input: { number: number; title: string }, ): Promise { - const pill = page.getByTestId("composer-github-attachment-pill").first(); + const pill = page.getByTestId("composer-github-attachment-pill").filter({ hasText: input.title }); await expect(pill).toBeVisible({ timeout: 10_000 }); await expect(pill).toContainText(`#${input.number}`); await expect(pill).toContainText(input.title); @@ -188,7 +188,7 @@ export async function openGithubPickerFromMenu(page: Page): Promise { await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 }); } -/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */ +/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */ export async function selectGithubOption( page: Page, searchTerm: string, diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index e3f0b1bba6..15e5c8bc37 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -1,7 +1,7 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { Locator, TestInfo } from "@playwright/test"; +import type { Locator } from "@playwright/test"; import { test, expect, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { buildAgentRoute } from "./mock-agent"; @@ -9,7 +9,6 @@ import { openCommandCenter } from "./command-center"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { seedWorkspace } from "./seed-client"; import { pluginRequirements } from "./plugin-fixture"; -import { waitForSettledPosition } from "./sheet-layout"; const PLUGIN_ID = "button-showcase"; const WIDE = { width: 1440, height: 900 }; @@ -166,17 +165,11 @@ async function command(page: Page, title: string) { await expect(panel).not.toBeVisible(); } -async function capture( +async function expectSurfaceVisible( page: Page, - testInfo: TestInfo, - name: string, subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }), ) { await expect(subject).toBeInViewport(); - await waitForSettledPosition(subject); - const file = testInfo.outputPath(`${name}.png`); - await page.screenshot({ path: file, animations: "disabled" }); - await testInfo.attach(name, { path: file, contentType: "image/png" }); } async function expectDetails(page: Page) { @@ -214,7 +207,7 @@ async function holdButton(page: Page, button: Locator) { await page.mouse.down(); } -async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) { +async function openCompactOverflowWithHeaderChrome(page: Page) { const hamburger = page.getByRole("button", { name: "Open menu", exact: true }); const action = page.getByRole("button", { name: "Deploy application", exact: true }); const overflow = page @@ -230,13 +223,12 @@ async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInf await expect(action).toHaveCSS("background-color", highlight); await holdButton(page, overflow); await expect(overflow).toHaveCSS("background-color", highlight); - await capture(page, testInfo, "12c-compact-overflow-pressed"); + await expectSurfaceVisible(page); await page.mouse.up(); } export async function withButtonShowcase( page: Page, - testInfo: TestInfo, run: (buttons: { openWideMenusAndPopovers(): Promise; runAndUpdateActions(): Promise; @@ -300,15 +292,9 @@ export async function withButtonShowcase( await expect(page.getByTestId("workspace-header-title")).toHaveText( "Healthy app after button failure", ); - await showcase.disable(); + await press(page, "Close deployment details"); await expect.poll(() => released.has(recovered)).toBe(true); - await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount( - 0, - ); - await showcase.setTitle("Healthy app after plugin unload"); - await expect(page.getByTestId("workspace-header-title")).toHaveText( - "Healthy app after plugin unload", - ); + await showcase.setTitle("Button showcase"); }), openWideMenusAndPopovers: () => test.step("wide buttons have placement-owned labels and chevrons", async () => { @@ -322,23 +308,19 @@ export async function withButtonShowcase( .getByRole("button", { name: "Composer checks", exact: true }) .getByTestId("plugin-button-chevron"), ).toHaveCount(0); - await capture(page, testInfo, "01-wide-buttons"); + await expectSurfaceVisible(page); await press(page, "Header checks"); await expect( page.getByRole("menuitem", { name: "Run checks", exact: true }), ).toBeVisible(); - await capture( + await expectSurfaceVisible( page, - testInfo, - "02-header-menu", page.getByRole("menuitem", { name: "Run checks", exact: true }), ); await choose(page, "Environment"); await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible(); - await capture( + await expectSurfaceVisible( page, - testInfo, - "03-header-submenu", page.getByRole("menuitem", { name: "Staging", exact: true }), ); await choose(page, "Staging"); @@ -346,10 +328,8 @@ export async function withButtonShowcase( await expect( page.getByRole("menuitem", { name: "Production locked", exact: true }), ).toBeDisabled(); - await capture( + await expectSurfaceVisible( page, - testInfo, - "03b-wide-overflow", page.getByRole("menuitem", { name: "Production locked", exact: true }), ); await choose(page, "Open deployment logs"); @@ -357,35 +337,27 @@ export async function withButtonShowcase( await press(page, "Close deployment details"); await press(page, "Header status"); await expectDetails(page); - await capture( + await expectSurfaceVisible( page, - testInfo, - "04-header-popover", page.getByRole("button", { name: "Close deployment details", exact: true }), ); await press(page, "Close deployment details"); await press(page, "Composer checks"); - await capture( + await expectSurfaceVisible( page, - testInfo, - "05-composer-menu", page.getByRole("menuitem", { name: "Run checks", exact: true }), ); await choose(page, "Deployment details"); await expectDetails(page); - await capture( + await expectSurfaceVisible( page, - testInfo, - "06-composer-menu-content", page.getByRole("button", { name: "Close deployment details", exact: true }), ); await press(page, "Close deployment details"); await press(page, "Composer status"); await expectDetails(page); - await capture( + await expectSurfaceVisible( page, - testInfo, - "07-composer-popover", page.getByRole("button", { name: "Close deployment details", exact: true }), ); await press(page, "Close deployment details"); @@ -396,17 +368,15 @@ export async function withButtonShowcase( await expect( page.getByText("Deployment failed. Try again.", { exact: true }), ).toBeVisible(); - await capture( + await expectSurfaceVisible( page, - testInfo, - "08-action-error", page.getByText("Deployment failed. Try again.", { exact: true }), ); await press(page, "Deploy application"); await expect( page.getByRole("button", { name: "Deploy application", exact: true }), ).toBeDisabled(); - await capture(page, testInfo, "09-action-pending"); + await expectSurfaceVisible(page); await command(page, "Finish deployment"); await expect( page.getByRole("button", { name: "Deploy application", exact: true }), @@ -415,7 +385,7 @@ export async function withButtonShowcase( await expect( page.getByRole("button", { name: "Run review", exact: true }), ).toBeDisabled(); - await capture(page, testInfo, "09b-composer-pending"); + await expectSurfaceVisible(page); await command(page, "Finish review"); await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText( "Reviewed", @@ -424,10 +394,10 @@ export async function withButtonShowcase( await expect( page.getByRole("button", { name: "Run review", exact: true }), ).toBeDisabled(); - await capture(page, testInfo, "10-disabled-buttons"); + await expectSurfaceVisible(page); await command(page, "Enable example buttons"); await command(page, "Use icon-only header"); - await capture(page, testInfo, "11-icon-only-header"); + await expectSurfaceVisible(page); await command(page, "Hide review button"); await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount( 0, @@ -456,30 +426,24 @@ export async function withButtonShowcase( "Button showcase with a long workspace title", ); await expectHeaderTextTruncated(page); - await capture(page, testInfo, "12-compact-buttons"); - await openCompactOverflowWithHeaderChrome(page, testInfo); + await expectSurfaceVisible(page); + await openCompactOverflowWithHeaderChrome(page); await expect( page.getByRole("menuitem", { name: "Production locked", exact: true }), ).toBeDisabled(); - await capture( + await expectSurfaceVisible( page, - testInfo, - "13-compact-header-overflow", page.getByRole("menuitem", { name: "Production locked", exact: true }), ); await choose(page, "Header checks"); - await capture( + await expectSurfaceVisible( page, - testInfo, - "14-compact-header-menu", page.getByRole("menuitem", { name: "Run checks", exact: true }), ); await choose(page, "Deployment details"); await expectDetails(page); - await capture( + await expectSurfaceVisible( page, - testInfo, - "15-compact-header-content", page.getByRole("button", { name: "Close deployment details", exact: true }), ); await press(page, "Close deployment details"); @@ -490,28 +454,22 @@ export async function withButtonShowcase( .getByRole("button", { name: "Header checks", exact: true }) .getByTestId("plugin-button-chevron"), ).toHaveCount(0); - await capture( + await expectSurfaceVisible( page, - testInfo, - "15b-compact-header-direct-menu", page.getByRole("menuitem", { name: "Run checks", exact: true }), ); await choose(page, "Run checks"); await showcase.setTitle("Button showcase"); await press(page, "Composer status"); await expectDetails(page); - await capture( + await expectSurfaceVisible( page, - testInfo, - "16-compact-composer-popover", page.getByRole("button", { name: "Close deployment details", exact: true }), ); await press(page, "Close deployment details"); await press(page, "Composer checks"); - await capture( + await expectSurfaceVisible( page, - testInfo, - "17-compact-composer-menu", page.getByRole("menuitem", { name: "Run checks", exact: true }), ); await choose(page, "Run checks"); @@ -529,16 +487,18 @@ export async function withButtonShowcase( ).toHaveCount(0); await showcase.setTitle("Button showcase"); await page.emulateMedia({ colorScheme: "dark" }); - await capture(page, testInfo, "18-dark-buttons"); + await expectSurfaceVisible(page); await press(page, "Header status"); await expectDetails(page); - await capture( + await expectSurfaceVisible( page, - testInfo, - "19-dark-popover", page.getByRole("button", { name: "Close deployment details", exact: true }), ); + const observation = page.getByLabel("Content observation", { exact: true }); + await expect(observation).not.toHaveText(""); + const owner = (await observation.textContent())!; await showcase.disable(); + await expect.poll(() => released.has(owner)).toBe(true); await expect( page.getByRole("button", { name: "Header status", exact: true }), ).toHaveCount(0); @@ -546,6 +506,10 @@ export async function withButtonShowcase( 0, ); await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0); + await showcase.setTitle("Healthy app after plugin unload"); + await expect(page.getByTestId("workspace-header-title")).toHaveText( + "Healthy app after plugin unload", + ); }), }); } finally { diff --git a/packages/app/e2e/support/helpers/replica-cache-measurement.ts b/packages/app/e2e/support/helpers/replica-cache-measurement.ts deleted file mode 100644 index 9876bf1d1e..0000000000 --- a/packages/app/e2e/support/helpers/replica-cache-measurement.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { CDPSession, Page } from "@playwright/test"; - -const REPLICA_CACHE_DATABASE = "paseo-replica-row-store"; -const REPLICA_CACHE_ROWS_STORE = "rows"; -const PERSIST_FRAME_NAMES = [ - "captureSessions", - "captureHost", - "queueReplicaChanges", - "apply", - "safeParse", - "stringify", - "setItem", - "measuredPut", -] as const; -const PERSIST_STACK_ANCHORS = new Set([ - "captureSessions", - "captureHost", - "queueReplicaChanges", - "persist", - "flushPending", - "measuredPut", -]); - -export interface ReplicaCacheMeasurementReport { - targetUrl: string; - observationMs: number; - persistCpuMs: number; - persistCpuFrames: Record; - flushes: number; - bytesWritten: { - total: number; - perFlush: number[]; - }; - redundantWrites: number; - storageDurationMs: number; - longTasks: { - count: number; - totalDurationMs: number; - maximumDurationMs: number; - durationsMs: number[]; - }; - restoreMs: number | null; -} - -interface BrowserMeasurementState { - flushes: Array<{ bytes: number; durationMs: number }>; - longTasks: number[]; - redundantWrites: number; -} - -interface ProfileNode { - id: number; - callFrame: { functionName: string; url: string }; - parent?: number; -} - -interface SamplingProfile { - nodes: ProfileNode[]; - samples?: number[]; - timeDeltas?: number[]; -} - -declare global { - interface Window { - __replicaCacheMeasurement?: BrowserMeasurementState; - __replicaCacheRestoreReadStartedAt?: number; - } -} - -export async function installReplicaCacheMeasurement(page: Page): Promise { - await page.addInitScript( - ({ databaseName, storeName }) => { - const encoder = new TextEncoder(); - const transactionWrites = new WeakMap< - IDBTransaction, - Array< - | { type: "put"; key: string; value: string; bytes: number; startedAt: number } - | { type: "delete"; key: string; bytes: 0; startedAt: number } - > - >(); - const lastValues = new Map(); - - const resolveStorageKey = ( - store: IDBObjectStore, - value: unknown, - explicitKey: IDBValidKey | undefined, - ): unknown => { - if (explicitKey !== undefined) return explicitKey; - if (value === null || typeof value !== "object") return null; - if (Array.isArray(store.keyPath)) { - return store.keyPath.map((part) => Reflect.get(value, part)); - } - return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null; - }; - - window.__replicaCacheMeasurement = { - flushes: [], - longTasks: [], - redundantWrites: 0, - }; - - const observeTransaction = (transaction: IDBTransaction) => { - let writes = transactionWrites.get(transaction); - if (writes) return writes; - writes = []; - transactionWrites.set(transaction, writes); - transaction.addEventListener("complete", () => { - const state = window.__replicaCacheMeasurement; - if (!state || writes!.length === 0) return; - let bytes = 0; - let startedAt = Number.POSITIVE_INFINITY; - for (const write of writes!) { - bytes += write.bytes; - startedAt = Math.min(startedAt, write.startedAt); - if (write.type === "put") { - if (lastValues.get(write.key) === write.value) state.redundantWrites += 1; - lastValues.set(write.key, write.value); - } else { - lastValues.delete(write.key); - } - } - state.flushes.push({ bytes, durationMs: performance.now() - startedAt }); - }); - return writes; - }; - - const originalPut = IDBObjectStore.prototype.put; - IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) { - const request = - key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key); - if (this.transaction.db.name !== databaseName || this.name !== storeName) return request; - const rowPayload = - value && typeof value === "object" ? Reflect.get(value, "payload") : undefined; - let serialized = JSON.stringify(value); - if (typeof value === "string") serialized = value; - if (typeof rowPayload === "string") serialized = rowPayload; - const storageKey = JSON.stringify(resolveStorageKey(this, value, key)); - observeTransaction(this.transaction).push({ - type: "put", - key: `${this.name}:${storageKey}`, - value: serialized, - bytes: encoder.encode(serialized).byteLength, - startedAt: performance.now(), - }); - return request; - }; - const originalDelete = IDBObjectStore.prototype.delete; - IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) { - const request = originalDelete.call(this, query); - if (this.transaction.db.name !== databaseName || this.name !== storeName) return request; - observeTransaction(this.transaction).push({ - type: "delete", - key: `${this.name}:${JSON.stringify(query)}`, - bytes: 0, - startedAt: performance.now(), - }); - return request; - }; - - const markRestoreRead = (store: IDBObjectStore) => { - if ( - store.transaction.db.name === databaseName && - store.name === storeName && - store.transaction.mode === "readonly" && - window.__replicaCacheRestoreReadStartedAt === undefined - ) { - window.__replicaCacheRestoreReadStartedAt = performance.now(); - } - }; - const originalGet = IDBObjectStore.prototype.get; - IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) { - markRestoreRead(this); - return originalGet.call(this, query); - }; - const originalGetAll = IDBObjectStore.prototype.getAll; - IDBObjectStore.prototype.getAll = function measuredGetAll( - query?: IDBValidKey | IDBKeyRange | null, - count?: number, - ) { - markRestoreRead(this); - return originalGetAll.call(this, query, count); - }; - const originalOpenCursor = IDBObjectStore.prototype.openCursor; - IDBObjectStore.prototype.openCursor = function measuredOpenCursor( - query?: IDBValidKey | IDBKeyRange | null, - direction?: IDBCursorDirection, - ) { - markRestoreRead(this); - return originalOpenCursor.call(this, query, direction); - }; - - if (typeof PerformanceObserver !== "undefined") { - const observer = new PerformanceObserver((entries) => { - const state = window.__replicaCacheMeasurement; - if (!state) return; - for (const entry of entries.getEntries()) state.longTasks.push(entry.duration); - }); - try { - observer.observe({ type: "longtask", buffered: true }); - } catch { - // Chromium exposes Long Tasks; leave an empty list on runtimes that do not. - } - } - }, - { databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE }, - ); -} - -export async function beginReplicaCacheObservation( - page: Page, -): Promise<{ cdp: CDPSession; startedAt: number }> { - await page.evaluate(() => { - const state = window.__replicaCacheMeasurement; - if (!state) throw new Error("Replica cache measurement is not installed"); - state.flushes = []; - state.longTasks = []; - state.redundantWrites = 0; - }); - const cdp = await page.context().newCDPSession(page); - await cdp.send("Profiler.enable"); - await cdp.send("Profiler.setSamplingInterval", { interval: 100 }); - await cdp.send("Profiler.start"); - return { cdp, startedAt: Date.now() }; -} - -export async function finishReplicaCacheObservation( - page: Page, - observation: { cdp: CDPSession; startedAt: number }, - targetUrl: string, -): Promise> { - const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile }; - await observation.cdp.detach(); - const browser = await page.evaluate(() => { - const state = window.__replicaCacheMeasurement; - if (!state) throw new Error("Replica cache measurement is not installed"); - return state; - }); - const cpu = summarizePersistCpu(result.profile); - const bytesPerFlush = browser.flushes.map((flush) => flush.bytes); - const longTaskDurations = browser.longTasks; - return { - targetUrl, - observationMs: Date.now() - observation.startedAt, - persistCpuMs: cpu.totalMs, - persistCpuFrames: cpu.byFrame, - flushes: browser.flushes.length, - bytesWritten: { - total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0), - perFlush: bytesPerFlush, - }, - redundantWrites: browser.redundantWrites, - storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0), - longTasks: { - count: longTaskDurations.length, - totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0), - maximumDurationMs: Math.max(0, ...longTaskDurations), - durationsMs: longTaskDurations, - }, - }; -} - -export async function measureReplicaCacheRestore( - page: Page, - targetUrl: string, - hydratedSelector: string, -): Promise { - await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => { - await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" }); - }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - const restoredAt = await page.waitForFunction( - (selector) => { - const startedAt = window.__replicaCacheRestoreReadStartedAt; - if (startedAt === undefined) return null; - const element = document.querySelector(selector); - if (!element || !(element instanceof HTMLElement) || element.offsetParent === null) - return null; - return performance.now(); - }, - hydratedSelector, - { timeout: 30_000 }, - ); - const restoredAtMs = await restoredAt.jsonValue(); - const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null); - return typeof restoredAtMs === "number" && startedAtMs !== null - ? restoredAtMs - startedAtMs - : null; -} - -function summarizePersistCpu(profile: SamplingProfile): { - totalMs: number; - byFrame: Record; -} { - const nodes = new Map(profile.nodes.map((node) => [node.id, node])); - const byFrame: Record = {}; - let totalMicroseconds = 0; - for (const [index, sampleId] of (profile.samples ?? []).entries()) { - const delta = profile.timeDeltas?.[index] ?? 0; - const stackFunctions = new Set(); - let node = nodes.get(sampleId); - while (node) { - stackFunctions.add(node.callFrame.functionName); - node = node.parent === undefined ? undefined : nodes.get(node.parent); - } - if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue; - const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name)); - if (matched.length === 0) continue; - totalMicroseconds += delta; - for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000; - } - return { totalMs: totalMicroseconds / 1_000, byFrame }; -} diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index d6ca1b38e4..c105dfd910 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -40,12 +40,6 @@ interface TimelineViewportSnapshot { scrollTop: number; } -interface PersistedCanonicalTimelineRange { - epoch: string; - startSeq: number; - endSeq: number; -} - export interface TimelinePresentationSnapshot { marker: string; position: TimelinePromptPositionSnapshot; @@ -57,11 +51,6 @@ interface TimelinePromptPositionSnapshot { top: number; } -interface OlderHistoryLoadingOperation { - animationStartTime: number | null; - marker: string; -} - interface OlderHistoryPages { expectRequestedPages(count: number): Promise; expectSettledWithRequestedPages(count: number): Promise; @@ -304,34 +293,6 @@ export async function reloadAgentTimelineFromPersistedReplica( await expectTimelinePromptVisible(page, agent.newestPrompt); } -export async function waitForPersistedCanonicalTimelineRange( - page: Page, - agentId: string, -): Promise { - const readRange = async () => { - const cache = await readReplicaCache(page); - if (cache?.version !== 6) return null; - const range = cache.hosts - ?.flatMap((host) => host.timelines) - .find((timeline) => timeline.agentId === agentId)?.range; - if ( - typeof range?.epoch !== "string" || - typeof range.startSeq !== "number" || - typeof range.endSeq !== "number" - ) { - return null; - } - return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }; - }; - - await expect.poll(readRange).not.toBeNull(); - const range = await readRange(); - if (!range) { - throw new Error(`Persisted canonical timeline range is missing for ${agentId}`); - } - return range; -} - export async function holdNextOlderTimelinePage( page: Page, agent: LongTimelineAgent, @@ -457,49 +418,6 @@ export async function expectTimelineViewportAnchoredAfterPrepend( expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2); } -export async function rememberOlderHistoryLoadingOperation( - page: Page, -): Promise { - const slot = page.getByTestId("load-older-history-spinner"); - await expect(slot).toBeVisible(); - const marker = `older-history-loading-${Date.now()}`; - await slot.evaluate((element, operationMarker) => { - const candidates = [element, ...Array.from(element.querySelectorAll("*"))]; - const animated = candidates.find( - (candidate) => getComputedStyle(candidate).animationName !== "none", - ); - if (!(animated instanceof HTMLElement)) { - throw new Error("Expected the older-history loader to contain an animated element"); - } - animated.dataset.olderHistoryLoadingOperation = operationMarker; - }, marker); - const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`); - await expect - .poll(async () => { - const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime); - return typeof startTime === "number" ? startTime : null; - }) - .not.toBeNull(); - const animationStartTime = await animated.evaluate((element) => { - const startTime = element.getAnimations()[0]?.startTime; - return typeof startTime === "number" ? startTime : null; - }); - return { animationStartTime, marker }; -} - -export async function expectSameOlderHistoryLoadingOperation( - page: Page, - operation: OlderHistoryLoadingOperation, -): Promise { - const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`); - await expect(animated).toBeVisible(); - const animationStartTime = await animated.evaluate((element) => { - const startTime = element.getAnimations()[0]?.startTime; - return typeof startTime === "number" ? startTime : null; - }); - expect(animationStartTime).toBe(operation.animationStartTime); -} - export async function expectTimelineAtHistoryStart(page: Page): Promise { await expect .poll(async () => (await readTimelineViewport(page)).scrollTop) diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts index 7685140229..ac40c7a3f6 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -110,6 +110,29 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page } await page.keyboard.press("Shift+?"); await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + await test.step("show the cleared shortcut in help, then bind new keys", async () => { + const help = await openCheatSheet(page); + const row = help.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); + await expect(row.getByText("Not set", { exact: true })).toBeVisible(); + await expect(help.getByText("?", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await openSettings(page); + await openSettingsSection(page, "shortcuts"); + + await openRowMenu(page); + await bind.click(); + await page.keyboard.press("Alt+Shift+K"); + await page.getByText("Done", { exact: true }).click(); + await expect(page.getByText("⌥⇧K", { exact: true })).toBeVisible(); + const reboundHelp = await openCheatSheet(page); + const reboundRow = reboundHelp.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); + await expect(reboundRow.getByText("⌥⇧K", { exact: true })).toBeVisible(); + await expect(reboundRow.getByText("?", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await openSettings(page); + await openSettingsSection(page, "shortcuts"); + }); + await openRowMenu(page); await page.getByTestId(`shortcut-reset-${SHORTCUTS_ROW}`).click(); await expect(notSet).toHaveCount(0); @@ -123,46 +146,3 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page } await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible({ timeout: 10_000 }); }); - -test("an unassigned shortcut lists no keys in the shortcuts cheat sheet", async ({ page }) => { - await openShortcutsSettings(page); - - await openRowMenu(page); - await page.getByTestId(`shortcut-clear-${SHORTCUTS_ROW}`).click(); - await expect(page.getByText("Not set", { exact: true })).toBeVisible(); - - const dialog = await openCheatSheet(page); - - // Addressed by testID rather than by filtering on its own text: an anchored - // `hasText` matches only the innermost node holding exactly that label, so a - // row-scoped assertion about anything *else* in the row finds nothing. - const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); - await expect(row).toBeVisible(); - // No badge pill, blank or otherwise, for a shortcut with no keys. - await expect(dialog.getByText("?", { exact: true })).toHaveCount(0); - // It says so, rather than leaving a silent gap where the keys were. Same words - // the settings row uses for the same state. - await expect(row.getByText("Not set", { exact: true })).toBeVisible(); -}); - -test("a rebound shortcut lists its new keys in the shortcuts cheat sheet", async ({ page }) => { - await openShortcutsSettings(page); - - const defaultKeys = page.getByText("?", { exact: true }); - await expect(defaultKeys).toBeVisible(); - - await page.getByTestId(`shortcut-actions-${SHORTCUTS_ROW}`).click(); - await page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`).click(); - await page.keyboard.press("Alt+Shift+K"); - await page.getByText("Done", { exact: true }).click(); - - const reboundKeys = page.getByText("⌥⇧K", { exact: true }); - await expect(reboundKeys).toBeVisible(); - await expect(defaultKeys).toHaveCount(0); - - const dialog = await openCheatSheet(page); - const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); - await expect(row.getByText("⌥⇧K", { exact: true })).toBeVisible(); - // The whole point: the cheat sheet stops advertising the shipped default. - await expect(row.getByText("?", { exact: true })).toHaveCount(0); -}); diff --git a/packages/desktop/e2e/updates.spec.ts b/packages/desktop/e2e/updates.spec.ts index 361002edac..9919acddb4 100644 --- a/packages/desktop/e2e/updates.spec.ts +++ b/packages/desktop/e2e/updates.spec.ts @@ -49,17 +49,6 @@ test.describe("Desktop updates", () => { await expect(page.getByTestId("host-page-update-button")).toBeDisabled(); }); - test("update banner appears in the sidebar when an app update is available", async ({ page }) => { - await installDesktopRuntime(page, { - serverId: getServerId(), - updateAvailable: true, - latestVersion: "1.2.3", - }); - await gotoAppShell(page); - - await expectUpdateBanner(page, "1.2.3"); - }); - test("clicking install shows the installing state on the callout", async ({ page }) => { await installDesktopRuntime(page, { serverId: getServerId(), @@ -105,9 +94,7 @@ test.describe("Desktop updates", () => { }); test.describe("Desktop daemon management", () => { - test("disabling built-in daemon management shows confirm dialog with correct copy", async ({ - page, - }) => { + test("cancelling the management confirmation preserves the enabled daemon", async ({ page }) => { const serverId = getServerId(); await installDesktopRuntime(page, { serverId, @@ -124,22 +111,6 @@ test.describe("Desktop daemon management", () => { await expectDaemonManagementEnabled(page); }); - test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => { - const serverId = getServerId(); - await installDesktopRuntime(page, { - serverId, - manageBuiltInDaemon: true, - ownedByDesktop: true, - confirmShouldAccept: false, - }); - await gotoAppShell(page); - await openDesktopSettings(page, serverId); - - await expectDaemonManagementEnabled(page); - await toggleDaemonManagement(page, "disable"); - await expectDaemonManagementEnabled(page); - }); - test("confirming the dialog disables built-in daemon management", async ({ page }) => { const serverId = getServerId(); await installDesktopRuntime(page, { @@ -176,93 +147,34 @@ test.describe("Desktop daemon management", () => { await expectDaemonStatusLogPath(page, realState.logPath); }); - test("stopping and restarting the daemon updates the PID", async ({ page }) => { - const serverId = getServerId(); - const realState = await loadRealDaemonState(); - await installDesktopRuntime(page, { - serverId, - manageBuiltInDaemon: true, - ownedByDesktop: true, - daemonPid: realState.pid, - daemonVersion: realState.version, - daemonLogPath: realState.logPath, - confirmShouldAccept: true, - }); - await gotoAppShell(page); - await openDesktopSettings(page, serverId); - - await expectDaemonStatusPid(page, realState.pid); - - await toggleDaemonManagement(page, "disable"); - await expectDaemonManagementDisabled(page); - await expectDaemonStatusPid(page, null); - - await toggleDaemonManagement(page, "enable"); - await expectDaemonManagementEnabled(page); - const newPid = realState.pid !== null ? realState.pid + 1000 : 11000; - await expectDaemonStatusPid(page, newPid); - }); - - test("pausing management preserves an attached legacy desktop-managed daemon", async ({ - page, - }) => { - const serverId = getServerId(); - const realState = await loadRealDaemonState(); - await installDesktopRuntime(page, { - serverId, - manageBuiltInDaemon: true, - ownedByDesktop: false, - daemonPid: realState.pid, - confirmShouldAccept: true, - }); - await gotoAppShell(page); - await openDesktopSettings(page, serverId); - - const dialog = await interceptDaemonManagementConfirmDialog(page); - expect(dialog).toEqual({ - title: "Pause built-in daemon", - message: "Pause automatic daemon management? The attached daemon will keep running.", - }); - await expectDaemonManagementDisabled(page); - await expectDaemonStatusPid(page, realState.pid); - - await toggleDaemonManagement(page, "enable"); - await expectDaemonManagementEnabled(page); - await expectDaemonStatusPid(page, realState.pid); - }); - - for (const ownedByDesktop of [false, true]) { - for (const confirmShouldAccept of [false, true]) { - test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the ${ownedByDesktop ? "owned" : "attached"} daemon`, async ({ - page, - }) => { - const serverId = getServerId(); - const realState = await loadRealDaemonState(); - const daemonHome = process.env.E2E_PASEO_HOME!; - await installDesktopRuntime(page, { - serverId, - daemonPid: realState.pid, - daemonHome, - ownedByDesktop, - confirmShouldAccept, - }); - await gotoAppShell(page); - await openDesktopSettings(page, serverId); - - const dialog = await interceptDaemonStopConfirmDialog(page); - expect(dialog).toEqual({ - title: "Stop local daemon?", - message: [ - ownedByDesktop - ? "This daemon was launched by this Desktop session." - : "This daemon was not launched by this Desktop session.", - `Home: ${daemonHome}`, - `Supervisor PID: ${realState.pid}`, - "Running agent work will be interrupted.", - ].join("\n"), - }); - await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid); + for (const confirmShouldAccept of [false, true]) { + test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the owned daemon`, async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + const daemonHome = process.env.E2E_PASEO_HOME!; + await installDesktopRuntime(page, { + serverId, + daemonPid: realState.pid, + daemonHome, + ownedByDesktop: true, + confirmShouldAccept, }); - } + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonStopConfirmDialog(page); + expect(dialog).toEqual({ + title: "Stop local daemon?", + message: [ + "This daemon was launched by this Desktop session.", + `Home: ${daemonHome}`, + `Supervisor PID: ${realState.pid}`, + "Running agent work will be interrupted.", + ].join("\n"), + }); + await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid); + }); } }); diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 74c6143c1f..55875e3523 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { mkdtempSync, rmSync } from "node:fs"; import { createServer, type ServerResponse } from "node:http"; -import net from "node:net"; import os from "node:os"; import path from "node:path"; import { Writable } from "node:stream"; @@ -367,7 +366,7 @@ describe("OpenCodeServerManager managed process ledger", () => { describe.runIf(process.platform === "win32")( "OpenCodeServerManager Windows OpenCode npm install", () => { - test("starts the helper server from opencode.exe instead of the npm opencode.cmd shim", async () => { + test("resolves the Windows npm shim to its native helper executable", async () => { const detectedOpenCode = await findExecutable("opencode"); expect(detectedOpenCode, "Windows CI must install opencode-ai before server tests").not.toBe( null, @@ -376,27 +375,21 @@ describe.runIf(process.platform === "win32")( const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-real-windows-")); const opencodeHomeDir = path.join(tempDir, "opencode-home"); - const managedProcesses = new FakeManagedProcesses(); + const runtime = new FakeOpenCodeServerRuntime([4604], { autoAnnounce: true }); const manager = new OpenCodeServerManager({ logger: createTestLogger(), - managedProcesses, + managedProcesses: runtime.managedProcesses, resolveHomeDir: () => opencodeHomeDir, + portAllocator: runtime.allocatePort, + spawnServerProcess: runtime.spawnServerProcess, + terminateProcess: runtime.terminateProcess, }); - let acquiredPort: number | null = null; try { - const acquisition = await manager.acquireDedicated({ - OPENCODE_AUTH_CONTENT: "{}", - OPENCODE_DISABLE_AUTOUPDATE: "1", - OPENCODE_DISABLE_AUTOCOMPACT: "1", - OPENCODE_DISABLE_MODELS_FETCH: "1", - OPENCODE_DISABLE_PROJECT_CONFIG: "1", - OPENCODE_PURE: "1", - OPENCODE_TEST_HOME: path.join(tempDir, "test-home"), - }); - acquiredPort = acquisition.server.port; - - const records = await managedProcesses.list(); + // Resolve the actual npm installation. Generation lifecycle coverage above + // owns process readiness; this regression owns the Windows launch command. + const acquisition = await manager.acquireCurrent(); + const records = await runtime.managedProcesses.list(); expect(records).toHaveLength(1); const record = records[0]!; expect(path.extname(record.command).toLowerCase()).toBe(".exe"); @@ -404,15 +397,13 @@ describe.runIf(process.platform === "win32")( path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(), ); expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase()); - expect(record.args).toEqual(["serve", "--port", String(acquiredPort)]); + expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]); + await acquisition.release(); } finally { - await manager.shutdown().catch(() => undefined); - if (acquiredPort !== null) { - await waitForClosedPort(acquiredPort, 5_000); - } - rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + await manager.shutdown(); + rmSync(tempDir, { recursive: true, force: true }); } - }, 60_000); + }); }, ); @@ -610,37 +601,3 @@ class FakeManagedProcesses implements ManagedProcessRegistry { }; } } - -async function waitForClosedPort(port: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!(await canConnectToPort(port))) { - return; - } - await sleep(100); - } - throw new Error(`OpenCode helper server still accepts connections on port ${port}`); -} - -function canConnectToPort(port: number): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ host: "127.0.0.1", port }); - let settled = false; - const settle = (connected: boolean) => { - if (settled) { - return; - } - settled = true; - socket.destroy(); - resolve(connected); - }; - - socket.once("connect", () => settle(true)); - socket.once("error", () => settle(false)); - socket.setTimeout(500, () => settle(false)); - }); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 380e3618e0..5d26c3d464 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1779,7 +1779,7 @@ export async function createPaseoDaemon( await pluginRuntime.stopAllPlugins(); unsubscribePluginProviders(); await hubRelationships.stop(); - workspaceReconciliation.dispose(); + await workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); diff --git a/packages/server/src/server/hub/hub-cli-contract.test.ts b/packages/server/src/server/hub/hub-cli-contract.test.ts index 32aa866aed..03f1ab96a2 100644 --- a/packages/server/src/server/hub/hub-cli-contract.test.ts +++ b/packages/server/src/server/hub/hub-cli-contract.test.ts @@ -10,13 +10,21 @@ afterEach(async () => { test("the Hub CLI connects, reports status, and disconnects through the daemon", async () => { relationship = await HubRelationshipHarness.start(); - const connected = await relationship.beginConnect().result; + const connected = await relationship.runCli([ + "hub", + "connect", + "https://hub.test", + "--api-key", + "hub-contract-api-key:ceremony-token", + "--permission", + "hub.execute", + ]); relationship.connectLatestSocket(); - const status = await relationship.status(); + const status = await relationship.runCli(["hub", "status"]); const enrollment = relationship.enrollmentAttempts()[0]; const secret = relationship.relationshipFile()?.credential?.secret; - const disconnected = await relationship.disconnect(); + const disconnected = await relationship.runCli(["hub", "disconnect"]); expect(connected.state).toBe("connecting"); expect(status.state).toBe("connected"); diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 0d091d66c1..f632971cc9 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -297,7 +297,7 @@ describe("Hub relationship", () => { version: 2, relationship: { permissions: ["hub.execute"] }, }); - expect(await relationship.status()).toMatchObject({ permissions: "hub.execute" }); + expect(await relationship.status()).toMatchObject({ permissions: ["hub.execute"] }); }); test.each([ @@ -419,7 +419,7 @@ describe("Hub relationship", () => { expect(disconnected).toMatchObject({ state: "not_connected", - hub: null, + hubOrigin: null, warning: expect.stringContaining("server-side revocation may remain pending"), }); expect(reconnected.state).toBe("connecting"); @@ -483,14 +483,14 @@ describe("Hub relationship", () => { const disconnected = await relationship.disconnect(true); - expect(disconnected).toMatchObject({ state: "not_connected", hub: null }); + expect(disconnected).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(relationship.revocationAttempts()).toBe(0); expect(relationship.relationshipFile()).toBeNull(); relationship.completeEnrollment(); await connecting.result; expect(relationship.socketAttempts()).toBe(0); - expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null }); }); test("daemon restart reconnects the same durable relationship", async () => { @@ -811,9 +811,9 @@ describe("Hub relationship", () => { expect(status).toMatchObject({ state: "revoked", daemonId: enrollment.daemonId, - hub: "https://hub.test", - permissions: "hub.execute", - error: reason, + hubOrigin: "https://hub.test", + permissions: ["hub.execute"], + lastError: reason, }); expect(persisted?.state).toBe("revoked"); expect(persisted?.relationship).toMatchObject({ @@ -848,10 +848,10 @@ describe("Hub relationship", () => { expect(disconnected).toMatchObject({ state: "not_connected", - hub: null, + hubOrigin: null, warning: expect.stringContaining("server-side revocation may remain pending"), }); - expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(relationship.revocationAttempts()).toBe(1); expect(relationship.socketAttempts()).toBe(1); expect(relationship.pendingRelationshipRetries()).toBe(0); @@ -864,10 +864,14 @@ describe("Hub relationship", () => { const disconnected = await relationship.disconnect(); - expect(disconnected).toMatchObject({ state: "not_connected", hub: null, error: null }); + expect(disconnected).toMatchObject({ + state: "not_connected", + hubOrigin: null, + lastError: null, + }); expect(disconnected).not.toHaveProperty("warning"); expect(relationship.revocationAttempts()).toBe(1); - expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", lastError: null }); }); test("force disconnect removes local authority without notifying the Hub", async () => { @@ -876,7 +880,7 @@ describe("Hub relationship", () => { const forced = await relationship.disconnect(true); - expect(forced).toMatchObject({ state: "not_connected", hub: null }); + expect(forced).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(forced).not.toHaveProperty("warning"); expect(relationship.revocationAttempts()).toBe(0); expect(relationship.loggableValues(forced)).not.toContain( @@ -895,7 +899,7 @@ describe("Hub relationship", () => { await relationship.startStoppedDaemon(); - expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(relationship.relationshipFile()).toBeNull(); expect(relationship.revocationAttempts()).toBe(0); expect(relationship.pendingRelationshipRetries()).toBe(0); diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 31fa1617f1..7c2ea1f0a7 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -425,7 +425,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote { } } -interface CliProcess { +interface RelationshipOperation { result: Promise>; } @@ -454,7 +454,7 @@ export class HubRelationshipHarness { private host = ""; private readonly logs: string[] = []; private readonly providerPrompts: AgentPromptInput[] = []; - private readonly cliProcesses = new Set>(); + private readonly operations = new Set>(); private readonly claimedCliSockets = new Set(); private readonly promptsToFail = new Set(); private failNextSessionClose = false; @@ -514,37 +514,44 @@ export class HubRelationshipHarness { this.remote.holdEnrollment(); } - beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess { + beginConnect( + token = "ceremony-token", + hubUrl = HUB_ORIGIN, + execute = true, + ): RelationshipOperation { return { - result: this.runCli([ - "hub", - "connect", - hubUrl, - "--api-key", - `hub-contract-api-key:${token}`, - ...(execute ? ["--permission", "hub.execute"] : []), - ]), + result: this.withClient( + async (client) => + (await client.connectHub(hubUrl, token, execute ? ["hub.execute"] : [])).status, + ), }; } async status(): Promise> { - return this.runCli(["hub", "status"]); + return this.withClient(async (client) => (await client.getHubStatus()).status); } async disconnect(force = false): Promise> { - return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]); + return this.withClient(async (client) => { + const { status, warning } = await client.disconnectHub(force); + return { ...status, ...(warning ? { warning } : {}) }; + }); } async grantPermission(permission: string): Promise> { - return this.runCli(["hub", "permissions", "grant", permission]); + return this.withClient( + async (client) => (await client.updateHubPermissions({ grant: [permission] })).status, + ); } async revokePermission(permission: string): Promise> { - return this.runCli(["hub", "permissions", "revoke", permission]); + return this.withClient( + async (client) => (await client.updateHubPermissions({ revoke: [permission] })).status, + ); } - beginDisconnect(force = false): CliProcess { - return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) }; + beginDisconnect(force = false): RelationshipOperation { + return { result: this.disconnect(force) }; } async relationshipStateBecomes(expected: string | null): Promise { @@ -1372,7 +1379,7 @@ export class HubRelationshipHarness { async close(): Promise { await this.stopDaemon(); - await Promise.allSettled(this.cliProcesses); + await Promise.allSettled(this.operations); await Promise.all( [...this.claimedCliSockets].map((socket) => this.closeClaimedCliSocket(socket)), ); @@ -1433,8 +1440,21 @@ export class HubRelationshipHarness { this.daemon = null; } - private runCli(args: string[]): Promise> { - return this.trackCli(this.executeCli(args)); + private withClient(action: (client: DaemonClient) => Promise): Promise { + return this.trackOperation( + (async () => { + const client = await this.trustedClient(); + try { + return await action(client); + } finally { + await client.close(); + } + })(), + ); + } + + runCli(args: string[]): Promise> { + return this.trackOperation(this.executeCli(args)); } private async executeCli(args: string[]): Promise> { @@ -1458,11 +1478,11 @@ export class HubRelationshipHarness { return parsed as Record; } - private trackCli(process: Promise): Promise { - this.cliProcesses.add(process); + private trackOperation(process: Promise): Promise { + this.operations.add(process); void process.then( - () => this.cliProcesses.delete(process), - () => this.cliProcesses.delete(process), + () => this.operations.delete(process), + () => this.operations.delete(process), ); return process; } diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index a5fa20c97f..3b9ab5d35e 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, watch as watchPath } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; @@ -30,6 +30,52 @@ afterEach(() => { for (const target of cleanupPaths.splice(0)) rmSync(target, { recursive: true, force: true }); }); +test("disposal releases a real project root watch before its directory is removed", async () => { + const root = mkdtempSync(path.join(tmpdir(), "project-watch-close-")); + cleanupPaths.push(root); + const logger = createTestLogger(); + const projects = new FileBackedProjectRegistry(path.join(root, "projects.json"), logger); + const workspaces = new FileBackedWorkspaceRegistry(path.join(root, "workspaces.json"), logger); + await projects.initialize(); + await workspaces.initialize(); + await projects.upsert( + createPersistedProjectRecord({ + projectId: "project-watch-close", + rootPath: root, + kind: "non_git", + displayName: "root watch", + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }), + ); + let released = false; + const service = new WorkspaceReconciliationService({ + projectRegistry: projects, + workspaceRegistry: workspaces, + logger, + watchProjectRoot: (rootPath, options, onChange, onError) => { + const watcher = watchPath(rootPath, options, onChange); + watcher.on("error", onError); + const closed = new Promise((resolve) => + watcher.once("close", () => { + released = true; + resolve(); + }), + ); + return { + close: () => { + watcher.close(); + return closed; + }, + }; + }, + }); + await service.start(); + await service.dispose(); + expect(released).toBe(true); + rmSync(root, { recursive: true }); +}); + interface ProjectSpec { id: string; root: string; @@ -188,7 +234,11 @@ class ObservedPlacements { const watch = { rootPath, onChange, onError, closed: false }; this.watches.push(watch); this.lifecycleEvents.push(`watch installed:${path.relative(this.home, rootPath)}`); - return { close: () => (watch.closed = true) }; + return { + close: async () => { + watch.closed = true; + }, + }; }; this.service = new WorkspaceReconciliationService({ projectRegistry: this.projects, @@ -277,8 +327,8 @@ class ObservedPlacements { await this.clock.advanceBy(elapsedMs); } - dispose(): void { - this.service.dispose(); + async dispose(): Promise { + await this.service.dispose(); } watchedRoots(): string[] { @@ -407,7 +457,7 @@ describe("observed workspace placement", () => { "registry mutation resolved:project-new", ]); expect(observed.gitReads).toBe(0); - observed.dispose(); + await observed.dispose(); }); test("deduplicates active root watches and tears them down on archive and remove", async () => { @@ -434,7 +484,7 @@ describe("observed workspace placement", () => { { kind: "remove", projectId: "project-duplicate" }, { kind: "remove", projectId: "project-remove" }, ]); - observed.dispose(); + await observed.dispose(); }); test("filters unrelated files and coalesces Git change bursts", async () => { @@ -450,7 +500,7 @@ describe("observed workspace placement", () => { observed.change("repo", null); await observed.advanceBy(DEBOUNCE_MS); expect(observed.gitReads).toBe(1); - observed.dispose(); + await observed.dispose(); }); test("recovers errored and temporarily unavailable watchers on the periodic pass", async () => { @@ -468,7 +518,7 @@ describe("observed workspace placement", () => { expect(observed.watchedRoots()).toEqual(["errored", "unavailable"]); expect(observed.closedRoots()).toEqual(["errored"]); - observed.dispose(); + await observed.dispose(); }); test("archives missing workspace directories on the periodic pass", async () => { @@ -482,7 +532,7 @@ describe("observed workspace placement", () => { expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String)); expect(observed.workspaceBatches).toEqual([["workspace-one"]]); - observed.dispose(); + await observed.dispose(); }); test("preserves a periodic full pass queued behind metadata reconciliation", async () => { @@ -504,7 +554,7 @@ describe("observed workspace placement", () => { expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String)); expect(observed.workspaceBatches).toEqual([["workspace-one"]]); - observed.dispose(); + await observed.dispose(); }); test("contains a failed reconciliation and converges on the next change", async () => { @@ -526,7 +576,7 @@ describe("observed workspace placement", () => { branch: "main", displayName: "Durable workspace-one", }); - observed.dispose(); + await observed.dispose(); }); test("deduplicates direct placement and project-derived workspace fanout", async () => { @@ -549,7 +599,7 @@ describe("observed workspace placement", () => { expect(observed.projectUpdates).toHaveLength(1); expect(observed.workspaceBatches).toEqual([["workspace-one", "workspace-two"]]); expect(new Set(observed.workspaceBatches[0]).size).toBe(2); - observed.dispose(); + await observed.dispose(); }); test("disposal suppresses in-flight mutations and reconciliation fanout", async () => { @@ -558,7 +608,7 @@ describe("observed workspace placement", () => { const registryRead = mutation.holdNextRegistryRead(); const adding = mutation.add({ id: "project-late", root: "late" }); await registryRead.started; - mutation.dispose(); + await mutation.dispose(); registryRead.release(); await adding; expect(mutation.projectUpdates).toEqual([]); @@ -573,7 +623,7 @@ describe("observed workspace placement", () => { reconciliation.change("repo", ".git"); const advancing = reconciliation.advanceBy(DEBOUNCE_MS); await workspaceRead.started; - reconciliation.dispose(); + await reconciliation.dispose(); workspaceRead.release(); await advancing; diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 59d2f9c124..d42f7334a6 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -25,7 +25,7 @@ export type ProjectUpdate = | { kind: "remove"; projectId: string }; interface ProjectRootWatcher { - close(): void; + close(): Promise; } export interface ProjectRootWatch { @@ -58,7 +58,13 @@ const systemClock: ReconciliationClock = { const watchProjectRoot: ProjectRootWatch = (rootPath, options, onChange, onError) => { const watcher = watchPath(rootPath, options, onChange); watcher.on("error", onError); - return watcher; + const closed = new Promise((resolve) => watcher.once("close", resolve)); + return { + close() { + watcher.close(); + return closed; + }, + }; }; export type ReconciliationChange = @@ -178,14 +184,15 @@ export class WorkspaceReconciliationService { this.rescanTimer.unref?.(); } - dispose(): void { + async dispose(): Promise { this.disposed = true; this.unsubscribeRegistry?.(); this.unsubscribeRegistry = null; if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer); if (this.debounceTimer) this.clock.clearTimeout(this.debounceTimer); - for (const { watcher } of this.watchers) watcher.close(); + const closing = this.watchers.map(({ watcher }) => watcher.close()); this.watchers.length = 0; + await Promise.all(closing); } /** Reconciles mutable Git facts only; never archives missing records. */ @@ -413,7 +420,7 @@ export class WorkspaceReconciliationService { areEquivalentPaths(project.rootPath, target.rootPath), ); if (stillActive) continue; - target.watcher.close(); + void target.watcher.close(); this.watchers.splice(index, 1); } @@ -433,7 +440,7 @@ export class WorkspaceReconciliationService { } }, (error) => { - watcher.close(); + void watcher.close(); const index = this.watchers.findIndex((target) => target.watcher === watcher); if (index >= 0) this.watchers.splice(index, 1); this.logger.warn(