From e109b866abf543c719a29820340c76cc61f73423 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 18:37:38 +0200 Subject: [PATCH] test(app): trim repeated browser journeys and scripted waits --- docs/testing.md | 2 + .../agent-timeline-scroll-smoothness.spec.ts | 175 ---- packages/app/e2e/browser/changes-pane.spec.ts | 880 +++++++++--------- packages/app/e2e/browser/chat-find.spec.ts | 98 +- .../command-center-agent-controls.spec.ts | 200 ++-- .../command-center-file-search.spec.ts | 33 +- .../browser/command-center-grouping.spec.ts | 58 -- ...ommand-center-workspace-management.spec.ts | 182 ++-- .../browser/command-center-workspaces.spec.ts | 44 +- .../new-workspace-composer-draft.spec.ts | 72 +- .../e2e/browser/new-workspace-entry.spec.ts | 30 +- .../new-workspace-isolation-memory.spec.ts | 100 -- .../new-workspace-launch-composer.spec.ts | 86 -- .../new-workspace-launch-memory.spec.ts | 103 -- .../new-workspace-launch-terminal.spec.ts | 72 +- .../browser/new-workspace-preselect.spec.ts | 33 +- .../app/e2e/browser/new-workspace.spec.ts | 80 +- .../app/e2e/browser/plugin-management.spec.ts | 12 +- .../browser/viewed-agent-timelines.spec.ts | 8 +- .../controlled-stream-plugin/index.server.ts | 79 ++ .../paseo-plugin.json | 1 + .../e2e/support/helpers/scroll-jumps.test.ts | 175 ++++ .../app/e2e/support/helpers/scroll-jumps.ts | 95 ++ .../helpers/timeline-scroll-smoothness.ts | 96 +- packages/app/vitest.config.ts | 5 +- 25 files changed, 1206 insertions(+), 1513 deletions(-) delete mode 100644 packages/app/e2e/browser/command-center-grouping.spec.ts delete mode 100644 packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts delete mode 100644 packages/app/e2e/browser/new-workspace-launch-composer.spec.ts delete mode 100644 packages/app/e2e/browser/new-workspace-launch-memory.spec.ts create mode 100644 packages/app/e2e/support/fixtures/controlled-stream-plugin/index.server.ts create mode 100644 packages/app/e2e/support/fixtures/controlled-stream-plugin/paseo-plugin.json create mode 100644 packages/app/e2e/support/helpers/scroll-jumps.test.ts create mode 100644 packages/app/e2e/support/helpers/scroll-jumps.ts diff --git a/docs/testing.md b/docs/testing.md index 8b1565d86d..c4e426fe90 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -154,6 +154,8 @@ npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop - Extract complex setup into reusable helpers - Test bodies should read like plain English - Build a vocabulary of test helpers that make complex flows simple +- Use one browser test per causal user journey, with named `test.step()` blocks for behaviors reached along the way. Reuse the open form, workspace, and browser state. Start another test for a distinct initial state, failure injection, race ordering, viewport, or destructive branch. +- Keep pure test-helper checks in `packages/app/e2e/support/helpers/*.test.ts`; the app unit job runs them without Metro, a daemon, or a browser. ### File naming diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts index 04286b85d3..bdfb442358 100644 --- a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -1,9 +1,7 @@ import { test } from "../support/fixtures"; import { expect } from "@playwright/test"; import { - findScrollJumps, expectImageSpaceReserved, - type ScrollFrame, observeTimelinePages, openOnlyTimelineTail, recordUpwardTraversal, @@ -14,179 +12,6 @@ import { holdParagraphThroughCompletion, } from "../support/helpers/timeline-scroll-smoothness"; -test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => { - const before: ScrollFrame = { - at: 1000, - scrollTop: 1000, - scrollHeight: 5000, - viewportHeight: 800, - virtualized: true, - loading: false, - rows: [ - { id: "image", top: -150, height: 150 }, - { id: "reading", top: 0, height: 100 }, - ], - anchor: "reading", - wheelTotal: 0, - lastWheelAt: 0, - inputFinishedAt: null, - imageLoads: 0, - mounted: 0, - unmounted: 0, - }; - const after: ScrollFrame = { - ...before, - at: 1400, - scrollTop: 900, - wheelTotal: 100, - lastWheelAt: 1050, - rows: [ - { id: "image", top: -50, height: 1150 }, - { id: "reading", top: 1100, height: 100 }, - ], - }; - expect(findScrollJumps([before, after])).toEqual([]); - expect( - findScrollJumps([ - { ...before, scrollTop: 200 }, - { - ...after, - wheelTotal: 1000, - scrollTop: 0, - rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })), - }, - ]), - ).toEqual([]); - expect( - findScrollJumps([ - before, - { ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) }, - ]), - ).toHaveLength(1); - expect( - findScrollJumps([ - { ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] }, - { ...after, rows: [{ id: "image", top: 600, height: 1150 }] }, - ]), - ).toHaveLength(1); -}); - -test("scroll detector accounts for delayed wheel input and growth below the new reader", () => { - const before: ScrollFrame = { - at: 1000, - scrollTop: 2000, - scrollHeight: 5000, - viewportHeight: 800, - virtualized: true, - loading: false, - rows: [ - { id: "new-reader", top: -520, height: 278 }, - { id: "image", top: -242, height: 184 }, - { id: "old-reader", top: -58, height: 130 }, - ], - anchor: "old-reader", - wheelTotal: 0, - lastWheelAt: 0, - inputFinishedAt: null, - imageLoads: 0, - mounted: 0, - unmounted: 0, - }; - const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 }; - const after: ScrollFrame = { - ...wheel, - at: 1220, - scrollTop: 1520, - scrollHeight: 5041, - anchor: "new-reader", - rows: [ - { id: "new-reader", top: -40, height: 278 }, - { id: "image", top: 238, height: 225 }, - { id: "old-reader", top: 463, height: 130 }, - ], - }; - expect(findScrollJumps([before, wheel, after])).toEqual([]); - // The previous 480 px input has already moved the viewport; the recent - // budget includes it, but only the next 480 px belongs to this frame. - const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 }; - const steadyAfter = { - ...after, - at: 1100, - wheelTotal: 960, - lastWheelAt: 1099, - scrollHeight: 6000, - rows: [ - { id: "new-reader", top: -40, height: 278 }, - { id: "image", top: 238, height: 1184 }, - { id: "old-reader", top: 1422, height: 130 }, - ], - }; - expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]); - - expect( - findScrollJumps([ - before, - wheel, - { - ...after, - rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), - }, - ]), - ).toHaveLength(1); -}); - -test("scroll detector follows the entered image through resize and virtual offset correction", () => { - for (const height of [225, 1632]) { - const before: ScrollFrame = { - at: 1000, - scrollTop: 2208, - scrollHeight: 5000, - viewportHeight: 516, - virtualized: true, - loading: false, - rows: [ - { id: "image", top: -704, height: 560 }, - { id: "caption", top: -144, height: 100 }, - { id: "old-reader", top: -44, height: 127 }, - ], - anchor: "old-reader", - wheelTotal: 0, - lastWheelAt: 0, - inputFinishedAt: null, - imageLoads: 0, - mounted: 3, - unmounted: 0, - }; - const resize = height - 560; - const after: ScrollFrame = { - ...before, - at: 1060, - // The virtualizer corrects 110px of estimates while the wheel moves 160px. - scrollTop: 1938, - scrollHeight: 5000 + resize - 110, - wheelTotal: 160, - lastWheelAt: 1020, - imageLoads: 1, - rows: [ - { id: "image", top: -544, height }, - { id: "caption", top: 16 + resize, height: 100 }, - { id: "old-reader", top: 116 + resize, height: 127 }, - ], - }; - expect(findScrollJumps([before, after])).toEqual([]); - // Resizing must not excuse additional viewport movement. - expect( - findScrollJumps([ - before, - { - ...after, - rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), - }, - ]), - ).toHaveLength(1); - } -}); - for (const cadence of scrollCadences) { test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({ page, diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 377adbb3b6..72921ae48f 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -262,15 +262,54 @@ test.afterEach(async () => { } }); -test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => { +test("clean Changes opens committed changes and navigates an empty comparison", async ({ + page, +}) => { const workspace = await createWorkspaceWithCommittedDiff(); - await openWorkspaceChangesSurface(page, workspace, 90_000); - const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); - const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); - await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed"); - await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); + await test.step("Changes opens the populated committed comparison for a clean checkout", async () => { + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); + await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed"); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName( + "committed-only.ts, +1, -0", + ); + }); + + await test.step("an empty Changes comparison links to the populated comparison", async () => { + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); + const mode = tree.getByTestId("changes-diff-status-trigger"); + await mode.click(); + await page.getByTestId("changes-diff-mode-committed").click(); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName( + "committed-only.ts, +1, -0", + ); + + await mode.click(); + await page.getByTestId("changes-diff-mode-uncommitted").click(); + + await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible(); + await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible(); + const seeCommitted = tree.getByRole("button", { name: "See committed changes" }); + await expect(seeCommitted).toBeVisible(); + await seeCommitted.click(); + await expect(mode).toContainText("Committed"); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName( + "committed-only.ts, +1, -0", + ); + }); + + await test.step("Jump to file stays out of the desktop diff and of an empty comparison", async () => { + await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0); + + const explorer = await openCompactChanges(page, workspace); + await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible(); + + await selectEmptyUncommittedComparison(page, explorer); + await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0); + }); }); test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => { @@ -297,29 +336,6 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn await expect(mode).toContainText("Uncommitted", { timeout: 30_000 }); }); -test("an empty Changes comparison links to the populated comparison", async ({ page }) => { - const workspace = await createWorkspaceWithCommittedDiff(); - await openWorkspaceChangesSurface(page, workspace); - - const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); - const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); - const mode = tree.getByTestId("changes-diff-status-trigger"); - await mode.click(); - await page.getByTestId("changes-diff-mode-committed").click(); - await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); - - await mode.click(); - await page.getByTestId("changes-diff-mode-uncommitted").click(); - - await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible(); - await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible(); - const seeCommitted = tree.getByRole("button", { name: "See committed changes" }); - await expect(seeCommitted).toBeVisible(); - await seeCommitted.click(); - await expect(mode).toContainText("Committed"); - await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); -}); - test("Changes comparison controls the working diff and tree selection focuses its file", async ({ page, }) => { @@ -335,223 +351,109 @@ test("Changes comparison controls the working diff and tree selection focuses it await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts"); }); -test("changes file actions open below the right-click without a reserved kebab", async ({ +test("horizontal diff scrolling preserves its header and offset across collapse", async ({ page, }) => { - const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - const deletedFileName = page.getByTestId("diff-file-1"); - await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/); - await deletedFileName.dblclick(); - expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); - await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0); - await page.getByTestId("diff-file-1-toggle").click({ button: "right" }); - await expect(page.getByText("Copy path")).toBeVisible(); - await page.getByText("Copy path", { exact: true }).click({ button: "right" }); - await expect(page.getByText("Copy path")).toBeVisible(); - await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0); - await page.keyboard.press("Escape"); - - const fileRow = page.getByTestId("diff-file-0-toggle"); - const fileRowBounds = await fileRow.boundingBox(); - expect(fileRowBounds).not.toBeNull(); - await fileRow.click({ button: "right", position: { x: 80, y: 10 } }); - await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible(); - const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox(); - expect(menuBounds).not.toBeNull(); - expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1); - expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10); - await page.getByTestId("diff-file-0-open-file").click(); - - await expect(page.getByTestId("workspace-file-pane")).toBeVisible(); - await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); -}); - -test("canvas file headers select without toggling for context menu and long press", async ({ - page, -}) => { - const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - const firstFile = page.getByTestId("diff-file-0-toggle"); - const deletedFile = page.getByTestId("diff-file-1-toggle"); - await deletedFile.click({ button: "right" }); - await expect(deletedFile).toHaveAttribute("aria-selected", "true"); - await expect(deletedFile).toHaveAttribute("aria-expanded", "true"); - await expect(page.getByText("Copy path")).toBeVisible(); - await page.keyboard.press("Escape"); - - await longPressFileHeader(page, firstFile); - await expect(firstFile).toHaveAttribute("aria-selected", "true"); - await expect(deletedFile).toHaveAttribute("aria-selected", "false"); - await expect(firstFile).toHaveAttribute("aria-expanded", "true"); -}); - -test("every interactive file header has the same hover feedback", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - const first = page.getByTestId("diff-file-0-toggle"); - const second = page.getByTestId("diff-file-1-toggle"); - const firstCanvas = page.getByTestId("git-diff-sticky-header-0"); - const secondCanvas = page.getByTestId("git-diff-sticky-header-1"); - const normalBackground = await headerCanvasPixel(firstCanvas, first, 10); - - await first.hover(); - await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground); - const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10); - - await first.click(); - await page.mouse.move(0, 0); - await expect(first).toHaveAttribute("aria-expanded", "false"); - await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground); - - await first.hover(); - await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground); - await second.hover(); - await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground); - await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground); -}); - -test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => { const wideLine = `export const wide = "${"wide".repeat(160)}";`; const workspace = await createWorkspaceWithExactSelectionDiff(wideLine); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); - const headerCanvas = page.getByTestId("git-diff-canvas"); - const header = page.getByTestId("diff-file-0-toggle"); - const beforePixel = await headerCanvasPixel(headerCanvas, header, 10); - const beforeBounds = await header.boundingBox(); + await test.step("horizontal body scrolling never moves or repaints the canvas header", async () => { + const headerCanvas = page.getByTestId("git-diff-canvas"); + const header = page.getByTestId("diff-file-0-toggle"); + const beforePixel = await headerCanvasPixel(headerCanvas, header, 10); + const beforeBounds = await header.boundingBox(); - await horizontallyScrollFirstFile(page, 320); - await page.waitForTimeout(50); + await horizontallyScrollFirstFile(page, 320); + await page.waitForTimeout(50); - expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel); - expect(await header.boundingBox()).toEqual(beforeBounds); + expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel); + expect(await header.boundingBox()).toEqual(beforeBounds); + }); + + await test.step("collapsing and expanding restores the real horizontal scroll offset", async () => { + const retainedOffset = await horizontallyScrollFirstFile(page, 320); + await page.getByTestId("diff-file-0-toggle").click(); + await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0); + await page.getByTestId("diff-file-0-toggle").click(); + + await expect.poll(() => readHorizontalDiffOffset(page)).toBe(retainedOffset); + }); }); -test("in-flow file headers move with the diff document", async ({ page }) => { - const workspace = await createWorkspaceWithStickyTransitionDiff(); - await useUnwrappedDiffLines(page); - await openWorkspaceChangesSurface(page, workspace); - - const motion = await measureInFlowHeaderMotion(page); - - expect(motion).toEqual({ headerSurface: -120, shell: -120 }); -}); - -test("the outgoing sticky header hands off without a gap or overlap", async ({ +test("file headers scroll with the document and hand off without gaps", async ({ page, }, testInfo) => { const workspace = await createWorkspaceWithStickyTransitionDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChangesSurface(page, workspace); - const scroller = page.getByTestId("git-diff-scroll"); - const first = page.getByTestId("diff-file-0-toggle"); - const second = page.getByTestId("diff-file-1-toggle"); - const secondTop = await second.evaluate((element) => { - const scrollElement = element.closest('[data-testid="git-diff-scroll"]'); - if (!scrollElement) return Number.NaN; - return ( - scrollElement.scrollTop + - element.getBoundingClientRect().top - - scrollElement.getBoundingClientRect().top - ); - }); - expect(secondTop).toBeGreaterThan(30); - await scroller.evaluate((element, scrollTop) => { - element.scrollTop = scrollTop; - element.dispatchEvent(new Event("scroll", { bubbles: false })); - }, secondTop - 15); - await expect(first).toBeAttached(); - await expect(second).toBeAttached(); + await test.step("in-flow file headers move with the diff document", async () => { + const motion = await measureInFlowHeaderMotion(page); - const [scrollBounds, firstBounds, secondBounds] = await Promise.all([ - scroller.boundingBox(), - first.boundingBox(), - second.boundingBox(), - ]); - if (!scrollBounds || !firstBounds || !secondBounds) { - throw new Error("Sticky transition geometry is unavailable"); - } - expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0); - expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0); - const transitionHits = await scroller.evaluate((element) => { - const bounds = element.getBoundingClientRect(); - return [14, 16].map( - (y) => + expect(motion).toEqual({ headerSurface: -120, shell: -120 }); + }); + + await test.step("the outgoing sticky header hands off without a gap or overlap", async () => { + const scroller = page.getByTestId("git-diff-scroll"); + const first = page.getByTestId("diff-file-0-toggle"); + const second = page.getByTestId("diff-file-1-toggle"); + const secondTop = await second.evaluate((element) => { + const scrollElement = element.closest('[data-testid="git-diff-scroll"]'); + if (!scrollElement) return Number.NaN; + return ( + scrollElement.scrollTop + + element.getBoundingClientRect().top - + scrollElement.getBoundingClientRect().top + ); + }); + expect(secondTop).toBeGreaterThan(30); + await scroller.evaluate((element, scrollTop) => { + element.scrollTop = scrollTop; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }, secondTop - 15); + await expect(first).toBeAttached(); + await expect(second).toBeAttached(); + + const [scrollBounds, firstBounds, secondBounds] = await Promise.all([ + scroller.boundingBox(), + first.boundingBox(), + second.boundingBox(), + ]); + if (!scrollBounds || !firstBounds || !secondBounds) { + throw new Error("Sticky transition geometry is unavailable"); + } + expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0); + expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0); + const transitionHits = await scroller.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + const headerAt = (y: number) => document .elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y) - ?.closest("[data-diff-header]")?.dataset.diffHeaderPath ?? null, - ); - }); - expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]); - await testInfo.attach("canvas-header-sticky-handoff", { - body: await page.getByTestId("git-diff-canvas-root").screenshot(), - contentType: "image/png", - }); + ?.closest("[data-diff-header]")?.dataset.diffHeaderPath ?? null; + return [headerAt(14), headerAt(16)]; + }); + expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]); + await testInfo.attach("canvas-header-sticky-handoff", { + body: await page.getByTestId("git-diff-canvas-root").screenshot(), + contentType: "image/png", + }); - await scroller.evaluate((element, scrollTop) => { - element.scrollTop = scrollTop; - element.dispatchEvent(new Event("scroll", { bubbles: false })); - }, secondTop); - await expect - .poll(async () => { - const bounds = await second.boundingBox(); - return bounds ? bounds.y - scrollBounds.y : Number.NaN; - }) - .toBeCloseTo(0, 0); + await scroller.evaluate((element, scrollTop) => { + element.scrollTop = scrollTop; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }, secondTop); + await expect + .poll(async () => { + const bounds = await second.boundingBox(); + return bounds ? bounds.y - scrollBounds.y : Number.NaN; + }) + .toBeCloseTo(0, 0); + }); }); -test("changes context menus duplicate files and folders", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); - await page.getByTestId("diff-file-0-duplicate").click(); - await expect - .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"))) - .toBe(AFTER); - - await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" }); - await page.getByTestId("diff-folder-src-duplicate").click(); - await expect - .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"))) - .toBe(AFTER); -}); - -test("changes tree aligns every file status after its diff stat", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff({ - includeDeletedFile: true, - includeUntrackedFile: true, - }); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - const tree = changesTree(page); - const modifiedRow = tree.getByTestId("diff-tree-file-0"); - const deletedRow = tree.getByTestId("diff-tree-file-1"); - const addedRow = tree.getByTestId("diff-tree-file-2"); - const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" }); - await expect(modifiedStatus).toBeVisible(); - await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible(); - await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible(); - - const [statBounds, statusBounds] = await Promise.all([ - modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(), - modifiedStatus.boundingBox(), - ]); - if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds"); - expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8); -}); - -test("the scrolling diff lists files in changes tree order", async ({ page }) => { +test("browse the ordered changes tree and recursively collapse its folders", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true, includeRootFileSortingFirst: true, @@ -559,72 +461,70 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) => await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); - const tree = changesTree(page); - const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]'); - const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); - const diffHeaders = panel.getByTestId(/^diff-file-\d+$/); + await test.step("the scrolling diff lists files in changes tree order", async () => { + const tree = changesTree(page); + const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]'); + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + const diffHeaders = panel.getByTestId(/^diff-file-\d+$/); - // Directories sort before files at every level, so the root note lands last - // even though "a-root-note.txt" compares below "src/..." as a whole string. - const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"]; + // Directories sort before files at every level, so the root note lands last + // even though "a-root-note.txt" compares below "src/..." as a whole string. + const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"]; - // Equal counts also prove no folder is collapsed: flattenDiffTree drops the - // descendants of a collapsed folder, which would make the tree a subsequence - // of the diff rather than a match, and mask a real ordering difference. - await expect(treeNames).toHaveCount(expected.length); - await expect(diffHeaders).toHaveCount(expected.length); - await expect(treeNames).toHaveText(expected); - const expectedHeaderNames = [ - "src/zz-folder/nested/changed.ts, +1, -1", - "src/zz-folder/root.ts, +1, -1", - "src/use-mounted-tab-set.ts, +37, -1", - "a-root-note.txt, +1, -0", - ]; - for (const [index, accessibleName] of expectedHeaderNames.entries()) { - await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName); - } -}); + // Equal counts also prove no folder is collapsed: flattenDiffTree drops the + // descendants of a collapsed folder, which would make the tree a subsequence + // of the diff rather than a match, and mask a real ordering difference. + await expect(treeNames).toHaveCount(expected.length); + await expect(diffHeaders).toHaveCount(expected.length); + await expect(treeNames).toHaveText(expected); + const expectedHeaderNames = [ + "src/zz-folder/nested/changed.ts, +1, -1", + "src/zz-folder/root.ts, +1, -1", + "src/use-mounted-tab-set.ts, +37, -1", + "a-root-note.txt, +1, -0", + ]; + for (const [index, accessibleName] of expectedHeaderNames.entries()) { + await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName); + } + }); -test("changes context menu recursively collapses descendant folders", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true }); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); + await test.step("changes context menu recursively collapses descendant folders", async () => { + const tree = changesTree(page); + await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + const rootRow = tree.getByTestId("diff-folder-src-toggle"); + const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true }); + await expect(rootRow).toHaveCSS("opacity", "1"); + await expect(rootLabel).toHaveCSS("opacity", "0.76"); + await rootRow.hover(); + await expect(rootLabel).toHaveCSS("opacity", "1"); + await page.mouse.move(0, 0); + const nestedLabel = tree + .getByTestId("diff-folder-src/zz-folder-toggle") + .getByText("zz-folder", { exact: true }); + const [rootBounds, nestedBounds] = await Promise.all([ + rootLabel.boundingBox(), + nestedLabel.boundingBox(), + ]); + if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds"); + expect(nestedBounds.x - rootBounds.x).toBe(12); - const tree = changesTree(page); - await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); - await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); - const rootRow = tree.getByTestId("diff-folder-src-toggle"); - const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true }); - await expect(rootRow).toHaveCSS("opacity", "1"); - await expect(rootLabel).toHaveCSS("opacity", "0.76"); - await rootRow.hover(); - await expect(rootLabel).toHaveCSS("opacity", "1"); - await page.mouse.move(0, 0); - const nestedLabel = tree - .getByTestId("diff-folder-src/zz-folder-toggle") - .getByText("zz-folder", { exact: true }); - const [rootBounds, nestedBounds] = await Promise.all([ - rootLabel.boundingBox(), - nestedLabel.boundingBox(), - ]); - if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds"); - expect(nestedBounds.x - rootBounds.x).toBe(12); + await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await page.getByTestId("diff-folder-src-collapse-folder").click(); + await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0); - await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); - await page.getByTestId("diff-folder-src-collapse-folder").click(); - await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0); + await tree.getByTestId("diff-folder-src-toggle").click(); + await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0); - await tree.getByTestId("diff-folder-src-toggle").click(); - await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); - await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0); + await tree.getByTestId("diff-folder-src/zz-folder-toggle").click(); + await expect(tree.getByText("root.ts", { exact: true })).toBeVisible(); + await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0); - await tree.getByTestId("diff-folder-src/zz-folder-toggle").click(); - await expect(tree.getByText("root.ts", { exact: true })).toBeVisible(); - await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); - await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0); - - await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); - await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible(); + await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); + await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible(); + }); }); test("changes context menus expose folder revert and restore a file after confirmation", async ({ @@ -761,70 +661,25 @@ test("shows a revert error returned by the daemon", async ({ page }) => { .toBe(AFTER); }); -test("Changes keeps review navigation and controls inside its workspace tab", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); +test("inspect, review, open, and duplicate changed files in the workspace", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ + includeDeletedFile: true, + includeUntrackedFile: true, + }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); - - const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true }); - await expect(visiblePanel).toBeVisible(); - await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0); - await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0); - await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0); - await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0); - await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1); - await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName( - "src/use-mounted-tab-set.ts, +37, -1", - ); - await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName( - "src/zz-deleted.ts, +0, -1", - ); - await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0); - await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1); - await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit"); - await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); - await visiblePanel.getByTestId("diff-file-0-toggle").click(); - await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible(); - - await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0); - await expect(page.getByTestId("changes-open-tab")).toHaveCount(0); - const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all"); - await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files"); - await collapseFiles.click(); - await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files"); - await collapseFiles.click(); - await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); - - const layout = visiblePanel.getByTestId("changes-toggle-layout"); - await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff"); - await layout.click(); - await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff"); - - const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace"); - await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace"); - const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines"); - await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines"); - await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute( - "aria-label", - "Refresh", - ); - await wrapLines.click(); - await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines"); - await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); - - await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); - await expect( - visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'), - ).toHaveCount(0, { timeout: 30_000 }); - await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible(); - await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER); - await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName( - "src/use-mounted-tab-set.ts, +37, -1", - { timeout: 30_000 }, - ); - await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName( - "src/zz-deleted.ts, +0, -1", - ); + await test.step("workspace controls update the comparison and presentation", async () => { + await inspectChangesWorkspaceControls(page, workspace); + }); + await setOpenChangesPresentation(page, { layout: "unified", wrapLines: false }); + await scrollDiffToTop(page); + await reviewChangedLine(page); + await scrollDiffToTop(page); + await inspectChangedFileHeaders(page); + await test.step("duplicate the file and its folder from Changes", async () => { + await page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).click(); + await duplicateChangedFiles(page, workspace); + }); }); test("compact Changes keeps its actions compact and menu-only", async ({ page }) => { @@ -890,18 +745,6 @@ test("compact Changes jumps to a file from the changed-files sheet", async ({ pa }); }); -test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => { - const committed = await createWorkspaceWithCommittedDiff(); - await openWorkspaceChangesSurface(page, committed, 90_000); - await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0); - - const explorer = await openCompactChanges(page, committed); - await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible(); - - await selectEmptyUncommittedComparison(page, explorer); - await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0); -}); - test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); @@ -1013,33 +856,6 @@ test("changes diff waits for configured fonts, then applies code typography chan expect(after.canvasPixels).not.toEqual(before.canvasPixels); }); -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); - const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }); - const focusedBackground = await changesTab.evaluate( - (element) => getComputedStyle(element).backgroundColor, - ); - - 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 ({ page, }) => { @@ -1058,21 +874,6 @@ test("split canvas creates a review on the changed side and keeps it in that col await cancelInlineReview(page); }); -test("scrolling clears the hovered review affordance", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - await hoverFirstChangedGutter(page); - await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible(); - await page.getByTestId("git-diff-scroll").evaluate((element) => { - element.scrollTop += 80; - element.dispatchEvent(new Event("scroll", { bubbles: false })); - }); - - await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0); -}); - test("canvas diff uses the overlay scrollbar and its thumb controls vertical scrolling", async ({ page, }) => { @@ -1120,33 +921,6 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end", await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2); }); -test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({ - page, -}) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - const body = page.getByTestId("diff-file-0-body"); - const canvas = page.getByTestId("git-diff-canvas"); - const [bodyBounds, fontSize] = await Promise.all([ - body.boundingBox(), - canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), - ]); - if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); - const lineHeight = Math.round(fontSize * 1.5); - await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5); - - const affordance = page.getByRole("button", { name: "Add review comment" }); - await expect(affordance).toBeVisible(); - await expect(affordance.locator("svg")).toBeVisible(); - const affordanceBounds = await affordance.boundingBox(); - expect(affordanceBounds?.width).toBeCloseTo(22, 0); - expect(affordanceBounds?.height).toBeCloseTo(22, 0); - await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); - await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text"); -}); - test("selects, preserves, copies, and dismisses diff text without opening a review", async ({ page, }) => { @@ -1297,23 +1071,6 @@ test("dragging within one wide grapheme or outside its cell never opens a review await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); -test("collapsing and expanding restores the real horizontal scroll offset", async ({ page }) => { - const workspace = await createWorkspaceWithExactSelectionDiff("x".repeat(400)); - await useUnwrappedDiffLines(page); - await openSelectionWorkspaceChanges(page, workspace); - - const retainedOffset = await horizontallyScrollFirstFile(page, 320); - await page.getByTestId("diff-file-0-toggle").click(); - await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0); - await page.getByTestId("diff-file-0-toggle").click(); - - await expect - .poll(() => - page.getByTestId("diff-file-0-horizontal-scroll").evaluate((element) => element.scrollLeft), - ) - .toBe(retainedOffset); -}); - async function useCodeFont(page: Page, codeFontSize: number): Promise { await page.addInitScript( ({ settingsKey, fontSize }) => { @@ -2218,3 +1975,242 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise { }); await expect(page.getByTestId("git-diff-canvas")).toBeVisible(); } + +async function readHorizontalDiffOffset(page: Page): Promise { + return page + .getByTestId("diff-file-0-horizontal-scroll") + .evaluate((element) => element.scrollLeft); +} + +async function inspectChangesWorkspaceControls( + page: Page, + workspace: DirtyWorkspace, +): Promise { + const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(visiblePanel).toBeVisible(); + await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0); + await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0); + await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0); + await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0); + await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1); + await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName( + "src/use-mounted-tab-set.ts, +37, -1", + ); + await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName( + "src/zz-deleted.ts, +0, -1", + ); + await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0); + await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1); + await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit"); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + await visiblePanel.getByTestId("diff-file-0-toggle").click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible(); + + await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0); + await expect(page.getByTestId("changes-open-tab")).toHaveCount(0); + const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all"); + await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files"); + await collapseFiles.click(); + await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files"); + await collapseFiles.click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + + const layout = visiblePanel.getByTestId("changes-toggle-layout"); + await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff"); + await layout.click(); + await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff"); + + const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace"); + await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace"); + const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines"); + await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines"); + await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute( + "aria-label", + "Refresh", + ); + await wrapLines.click(); + await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines"); + await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); + + await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); + await expect( + visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'), + ).toHaveCount(0, { timeout: 30_000 }); + await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible(); + await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER); + await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName( + "src/use-mounted-tab-set.ts, +37, -1", + { timeout: 30_000 }, + ); + await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName( + "src/zz-deleted.ts, +0, -1", + ); +} + +async function reviewChangedLine(page: Page): Promise { + await test.step("the whole reviewable row reveals the gutter affordance and uses a text cursor", async () => { + const body = page.getByTestId("diff-file-0-body"); + const canvas = page.getByTestId("git-diff-canvas"); + const [bodyBounds, fontSize] = await Promise.all([ + body.boundingBox(), + canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), + ]); + if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); + const lineHeight = Math.round(fontSize * 1.5); + await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5); + + const affordance = page.getByRole("button", { name: "Add review comment" }); + await expect(affordance).toBeVisible(); + await expect(affordance.locator("svg")).toBeVisible(); + const affordanceBounds = await affordance.boundingBox(); + expect(affordanceBounds?.width).toBeCloseTo(22, 0); + expect(affordanceBounds?.height).toBeCloseTo(22, 0); + await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text"); + }); + + await test.step("creates, cancels, edits, and deletes a review while keeping Changes focused", async () => { + const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }); + const focusedBackground = await changesTab.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + + 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); + }); + }); + + await test.step("scrolling clears the hovered review affordance", async () => { + await hoverFirstChangedGutter(page); + await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible(); + await page.getByTestId("git-diff-scroll").evaluate((element) => { + element.scrollTop += 80; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }); + + await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0); + }); +} + +async function inspectChangedFileHeaders(page: Page): Promise { + await test.step("file statuses follow their diff stats", async () => { + const tree = changesTree(page); + const modifiedRow = tree.getByTestId("diff-tree-file-0"); + const deletedRow = tree.getByTestId("diff-tree-file-1"); + const addedRow = tree.getByTestId("diff-tree-file-2"); + const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" }); + await expect(modifiedStatus).toBeVisible(); + await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible(); + await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible(); + + const [statBounds, statusBounds] = await Promise.all([ + modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(), + modifiedStatus.boundingBox(), + ]); + if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds"); + expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8); + }); + + await test.step("every interactive file header has the same hover feedback", async () => { + const first = page.getByTestId("diff-file-0-toggle"); + const second = page.getByTestId("diff-file-1-toggle"); + const firstCanvas = page.getByTestId("git-diff-sticky-header-0"); + const secondCanvas = page.getByTestId("git-diff-sticky-header-1"); + const normalBackground = await headerCanvasPixel(firstCanvas, first, 10); + + await first.hover(); + await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground); + const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10); + + await first.click(); + await page.mouse.move(0, 0); + await expect(first).toHaveAttribute("aria-expanded", "false"); + await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground); + + await first.hover(); + await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground); + await second.hover(); + await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground); + await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground); + await first.click(); + await page.mouse.move(0, 0); + await expect(first).toHaveAttribute("aria-expanded", "true"); + }); + + await test.step("canvas file headers select without toggling for context menu and long press", async () => { + const firstFile = page.getByTestId("diff-file-0-toggle"); + const deletedFile = page.getByTestId("diff-file-1-toggle"); + await deletedFile.click({ button: "right" }); + await expect(deletedFile).toHaveAttribute("aria-selected", "true"); + await expect(deletedFile).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByText("Copy path")).toBeVisible(); + await page.keyboard.press("Escape"); + + await longPressFileHeader(page, firstFile); + await expect(firstFile).toHaveAttribute("aria-selected", "true"); + await expect(deletedFile).toHaveAttribute("aria-selected", "false"); + await expect(firstFile).toHaveAttribute("aria-expanded", "true"); + }); + + await test.step("changes file actions open below the right-click without a reserved kebab", async () => { + const deletedFileName = page.getByTestId("diff-file-1"); + await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/); + await deletedFileName.dblclick(); + expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); + await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0); + await page.getByTestId("diff-file-1-toggle").click({ button: "right" }); + await expect(page.getByText("Copy path")).toBeVisible(); + await page.getByText("Copy path", { exact: true }).click({ button: "right" }); + await expect(page.getByText("Copy path")).toBeVisible(); + await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0); + await page.keyboard.press("Escape"); + + const fileRow = page.getByTestId("diff-file-0-toggle"); + const fileRowBounds = await fileRow.boundingBox(); + expect(fileRowBounds).not.toBeNull(); + await fileRow.click({ button: "right", position: { x: 80, y: 10 } }); + await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible(); + const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox(); + expect(menuBounds).not.toBeNull(); + expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1); + expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10); + await page.getByTestId("diff-file-0-open-file").click(); + + await expect(page.getByTestId("workspace-file-pane")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); + }); +} + +async function duplicateChangedFiles(page: Page, workspace: DirtyWorkspace): Promise { + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await page.getByTestId("diff-file-0-duplicate").click(); + await expect + .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"))) + .toBe(AFTER); + + await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await page.getByTestId("diff-folder-src-duplicate").click(); + await expect + .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"))) + .toBe(AFTER); +} + +async function scrollDiffToTop(page: Page): Promise { + const scroll = page.getByTestId("git-diff-scroll"); + await scroll.evaluate((element) => { + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }); + await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBe(0); +} diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index b29a5f5b89..f0ad55b743 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { TestInfo } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { @@ -11,6 +12,8 @@ import { expectReconnectingToastVisible, } from "../support/helpers/workspace-ui"; import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; import { composerLocator, fillComposerDraft, @@ -24,11 +27,13 @@ const MAC_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" "); -const STREAMED_RESPONSE = [ +const STREAMED_TAIL = "STREAMED-REPLY-FINAL-TAIL"; +const HYDRATED_PARTIAL = "Hydrated partial continuation"; +const STREAMED_PREFIX = [ "First paragraph with alpha-needle and pair-needle inside.", "Second paragraph with beta-needle and pair-needle inside.", `Third paragraph ${FILLER}`, -].join("\n\n"); +].map((paragraph) => `${paragraph}\n\n`); const RESPONSE = 'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell'; @@ -282,50 +287,97 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag } }); -interface StreamedReplyChat extends MockAgentWorkspace { - /** Resolves once the streamed reply has finished arriving. */ +interface StreamedReplyChat { + cleanup(): Promise; + advancePastVisiblePrefix(): Promise; + expectHydratedPartial(): Promise; replyFinished(): Promise; } -/** - * Opens the chat first, then streams a multi-block reply into it and waits for a - * later block, so the block holding the early needles is no longer the growing row. - */ +/** The provider owns the pause, so both search and hydration see incomplete output. */ async function openChatStreamingMultiBlockReply(page: Page): Promise { - const agent = await seedMockAgentWorkspace({ - repoPrefix: "chat-find-live-blocks-", - title: "Chat Find live blocks", - featureValues: { - mockStreamingAssistantResponse: STREAMED_RESPONSE, - mockStreamingAssistantIntervalMs: 60, - }, - }); + const workspace = await seedWorkspace({ repoPrefix: "chat-find-live-blocks-" }); + const plugins = await connectNewWorkspaceDaemonClient(); + const previous = await plugins.getDaemonConfig(); + const gate = await installDaemonWebSocketGate(page); + const append = (chunks: string[], finish = false) => + plugins.invokePluginRpc("controlled-stream-test", "append", { chunks, finish }); + let started = false; + const cleanup = async () => { + try { + if (started) await append([], true); + await plugins.removePlugin("controlled-stream-test"); + await plugins.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + } finally { + await plugins.close(); + await workspace.cleanup(); + } + }; try { - await openAgentRoute(page, agent); - await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer"); - await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({ - timeout: 30_000, + await plugins.patchDaemonConfig({ pluginsEnabled: true }); + await plugins.installDirectoryPlugin( + path.resolve(__dirname, "../support/fixtures/controlled-stream-plugin"), + ); + const agent = await workspace.client.createAgent({ + provider: "controlled-stream", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Chat Find live blocks", }); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + await workspace.client.sendAgentMessage(agent.id, "Stream a multi block answer"); + started = true; + await append(STREAMED_PREFIX); + await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible(); return { - ...agent, + cleanup, + advancePastVisiblePrefix: async () => { + const count = gate.getAgentStreamItemCount("assistant_message"); + gate.setAgentStreamSuppressed(true); + await append([HYDRATED_PARTIAL]); + await gate.waitForAgentStreamItem("assistant_message", count + 1); + await expect(page.getByTestId("assistant-message").last()).not.toContainText( + HYDRATED_PARTIAL, + ); + }, + expectHydratedPartial: async () => { + // A newer daemon row forces Find to fetch the real timeline instead of + // matching only the browser's streamed prefix. + await expect(page.getByTestId("assistant-message").last()).toContainText(HYDRATED_PARTIAL); + expect(gate.getTimelineRequestCount("before", agent.id)).toBeGreaterThan(0); + }, replyFinished: async () => { - await agent.client.waitForFinish(agent.agentId, 60_000); + gate.setAgentStreamSuppressed(false); + await append([`\n\n${STREAMED_TAIL}`], true); + await workspace.client.waitForFinish(agent.id, 15_000); }, }; } catch (error) { - await agent.cleanup(); + await cleanup(); throw error; } } +async function expectIncompleteStreamedReply(page: Page): Promise { + await expect(page.getByTestId("assistant-message").last()).toContainText("beta-needle"); + await expect(page.getByTestId("assistant-message").last()).toContainText("filler399"); + await expect(page.getByTestId("assistant-message").last()).not.toContainText(STREAMED_TAIL); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible(); +} + test("finds a hit in a message that streamed while the chat was open", async ({ page }) => { test.setTimeout(120_000); const chat = await openChatStreamingMultiBlockReply(page); try { + await expectIncompleteStreamedReply(page); + await chat.advancePastVisiblePrefix(); await openChatFind(page); // The first-paragraph hit must remain searchable while the same message grows. await expectFindStatus(page, "alpha-needle", "1 of 1"); + await chat.expectHydratedPartial(); + await expectIncompleteStreamedReply(page); await chat.replyFinished(); + await expect(page.getByTestId("assistant-message").last()).toContainText(STREAMED_TAIL); await expectFindStatus(page, "beta-needle", "1 of 1"); // One message, two paragraphs, one count across both. await expectFindStatus(page, "pair-needle", "1 of 2"); diff --git a/packages/app/e2e/browser/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts index 658e0e1123..9c502c71d6 100644 --- a/packages/app/e2e/browser/command-center-agent-controls.spec.ts +++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts @@ -4,7 +4,6 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman import { applyCommandCenterAgentControls, chooseCommandCenterAgentControl, - openCommandCenterForAgent, expectCommandCenterAgentControlSelected, expectFocusedAgentControls, expectWorkspaceAgentConfiguration, @@ -42,37 +41,93 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page): test.describe("Command Center agent controls", () => { test.describe.configure({ timeout: 180_000 }); - test("changes a running agent setting and preserves its selected row", async ({ page }) => { + test("search, select, and re-rank live agent controls before navigating to an action", async ({ + page, + }) => { const workspace = await seedMockAgentWorkspace({ repoPrefix: "command-center-live-controls-", title: "Command Center live controls", model: "five-minute-stream", }); try { - // A stale runtime mode makes selecting the provider's supported mode - // exercise the live setting RPC instead of the selected-row no-op. await workspace.client.setAgentMode(workspace.agentId, "legacy-mode"); - await openAgentRoute(page, { - workspaceId: workspace.workspaceId, - agentId: workspace.agentId, + await openAgentRoute(page, workspace); + await test.step("changes a running agent setting and preserves its selected row", async () => { + await openCommandCenter(page); + await chooseCommandCenterAgentControl({ + page, + query: "load test", + choice: "Mode › Load test", + }); + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); + await openCommandCenter(page); + await expectCommandCenterAgentControlSelected({ + page, + query: "load test", + choice: "Mode › Load test", + }); + + await closeCommandCenter(page); }); - await openCommandCenter(page); - await chooseCommandCenterAgentControl({ - page, - query: "load test", - choice: "Mode › Load test", + await test.step("matches query tokens in any order", async () => { + await workspace.client.setAgentMode(workspace.agentId, "legacy-mode"); + await openCommandCenter(page); + await chooseCommandCenterAgentControl({ + page, + query: "test load", + choice: "Mode › Load test", + }); + + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); }); - await expectWorkspaceAgentConfiguration(workspace, { - id: workspace.agentId, - provider: "mock", - model: "five-minute-stream", - modeId: "load-test", + await test.step("activates the best-matching row, not the highest-ranked group", async () => { + await workspace.client.setAgentMode(workspace.agentId, "approval-test"); + const panel = await openCommandCenter(page); + const input = panel.getByTestId("command-center-input"); + await input.fill("load"); + await page.keyboard.press("Enter"); + + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); }); - await openCommandCenter(page); - await expectCommandCenterAgentControlSelected({ - page, - query: "load test", - choice: "Mode › Load test", + await test.step("drops an arrow-key selection once the query changes", async () => { + await workspace.client.setAgentMode(workspace.agentId, "approval-test"); + const panel = await openCommandCenter(page); + const input = panel.getByTestId("command-center-input"); + await input.fill("loa"); + // Moves the highlight onto a model row, which still matches after the next keystroke. + await page.keyboard.press("ArrowDown"); + await input.fill("load"); + await page.keyboard.press("Enter"); + + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); + }); + await test.step("keeps an action first when the action is the best match", async () => { + const panel = await openCommandCenter(page); + const input = panel.getByTestId("command-center-input"); + await input.fill("sched"); + await page.keyboard.press("Enter"); + + await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 }); }); } finally { await workspace.cleanup(); @@ -100,107 +155,6 @@ test.describe("Command Center agent controls", () => { } }); - test("activates the best-matching row, not the highest-ranked group", async ({ page }) => { - // "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row - // matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a - // model. Asserting both model and mode catches either row winning. - const workspace = await seedMockAgentWorkspace({ - repoPrefix: "command-center-relevance-", - title: "Command Center relevance", - model: "five-minute-stream", - }); - try { - await workspace.client.setAgentMode(workspace.agentId, "approval-test"); - const input = await openCommandCenterForAgent(page, workspace); - await input.fill("load"); - await page.keyboard.press("Enter"); - - await expectWorkspaceAgentConfiguration(workspace, { - id: workspace.agentId, - provider: "mock", - model: "five-minute-stream", - modeId: "load-test", - }); - } finally { - await workspace.cleanup(); - } - }); - - test("matches query tokens in any order", async ({ page }) => { - // "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never - // a substring of it. Only per-token matching finds this row. - const workspace = await seedMockAgentWorkspace({ - repoPrefix: "command-center-tokenized-", - title: "Command Center tokenized search", - model: "five-minute-stream", - }); - try { - await workspace.client.setAgentMode(workspace.agentId, "legacy-mode"); - await openAgentRoute(page, { - workspaceId: workspace.workspaceId, - agentId: workspace.agentId, - }); - await openCommandCenter(page); - await chooseCommandCenterAgentControl({ - page, - query: "test load", - choice: "Mode › Load test", - }); - - await expectWorkspaceAgentConfiguration(workspace, { - id: workspace.agentId, - provider: "mock", - model: "five-minute-stream", - modeId: "load-test", - }); - } finally { - await workspace.cleanup(); - } - }); - - test("keeps an action first when the action is the best match", async ({ page }) => { - const workspace = await seedMockAgentWorkspace({ - repoPrefix: "command-center-action-priority-", - title: "Command Center action priority", - model: "five-minute-stream", - }); - try { - const input = await openCommandCenterForAgent(page, workspace); - await input.fill("sched"); - await page.keyboard.press("Enter"); - - await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 }); - } finally { - await workspace.cleanup(); - } - }); - - test("drops an arrow-key selection once the query changes", async ({ page }) => { - const workspace = await seedMockAgentWorkspace({ - repoPrefix: "command-center-active-reset-", - title: "Command Center active reset", - model: "five-minute-stream", - }); - try { - await workspace.client.setAgentMode(workspace.agentId, "approval-test"); - const input = await openCommandCenterForAgent(page, workspace); - await input.fill("loa"); - // Moves the highlight onto a model row, which still matches after the next keystroke. - await page.keyboard.press("ArrowDown"); - await input.fill("load"); - await page.keyboard.press("Enter"); - - await expectWorkspaceAgentConfiguration(workspace, { - id: workspace.agentId, - provider: "mock", - model: "five-minute-stream", - modeId: "load-test", - }); - } finally { - await workspace.cleanup(); - } - }); - test("applies draft model and setting choices to the created agent", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" }); await seedMockDraftPreferences(page); diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 5964dcc978..3c9f658690 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -82,31 +82,20 @@ test("workspace file search stays geometrically stable through delayed loading a await row.click(); await expectFileTabOpen(page, FILE_PATH); - } finally { - await seeded.cleanup(); - } -}); + await test.step("dropping the files scope leaves the search row the same height", async () => { + await page.keyboard.press("Meta+P"); -test("dropping the files scope leaves the search row the same height", async ({ page }) => { - const seeded = await seedWorkspace({ - repoPrefix: "command-center-file-search-scope-", - title: "Scope chip height", - }); + const reopenedPanel = page.getByTestId("command-center-panel"); + await expect(reopenedPanel).toBeVisible({ timeout: 30_000 }); + const header = reopenedPanel.getByTestId("command-center-header"); + const scoped = await header.boundingBox(); - try { - await gotoWorkspace(page, seeded.workspaceId); - await page.keyboard.press("Meta+P"); + await page.getByTestId("command-center-files-scope").click(); + await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0); + const unscoped = await header.boundingBox(); - const panel = page.getByTestId("command-center-panel"); - await expect(panel).toBeVisible({ timeout: 30_000 }); - const header = panel.getByTestId("command-center-header"); - const scoped = await header.boundingBox(); - - await page.getByTestId("command-center-files-scope").click(); - await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0); - const unscoped = await header.boundingBox(); - - expect(scoped?.height).toBe(unscoped?.height); + expect(scoped?.height).toBe(unscoped?.height); + }); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/browser/command-center-grouping.spec.ts b/packages/app/e2e/browser/command-center-grouping.spec.ts deleted file mode 100644 index 8bf78177e2..0000000000 --- a/packages/app/e2e/browser/command-center-grouping.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { expect, test, type Page } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; -import { openCommandCenter } from "../support/helpers/command-center"; -import { seedWorkspace } from "../support/helpers/seed-client"; - -const GROUP_BY_STATUS = "Group by status"; -const GROUP_BY_PROJECT = "Group by project"; - -// Result rows carry no testID of their own, so the entries are addressed by their visible label. -async function runGroupingEntry(page: Page, label: string, absent: string): Promise { - const panel = await openCommandCenter(page); - await panel.getByTestId("command-center-input").fill("group"); - - const entry = panel.getByText(label, { exact: true }); - await expect(entry).toBeVisible({ timeout: 30_000 }); - // The entry always names the mode you are not in, so its opposite must never be listed. - await expect(panel.getByText(absent, { exact: true })).toHaveCount(0); - - await entry.click(); - await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 }); -} - -test.describe("Command center sidebar grouping", () => { - test.describe.configure({ timeout: 120_000 }); - - test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => { - const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" }); - - try { - await gotoAppShell(page); - const projectList = page.getByTestId("sidebar-project-workspace-list-scroll"); - const statusList = page.getByTestId("sidebar-status-list-scroll"); - await expect(projectList).toBeVisible({ timeout: 30_000 }); - - // Query-only: the grouping entry stays out of the default empty-query list. - const panel = await openCommandCenter(page); - await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0); - await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0); - await page.keyboard.press("Escape"); - await expect(panel).not.toBeVisible({ timeout: 30_000 }); - - await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT); - await expect(statusList).toBeVisible({ timeout: 30_000 }); - await expect(projectList).toHaveCount(0); - - // The only assertion that catches a flip which never reached persisted storage. - await page.reload(); - await expect(statusList).toBeVisible({ timeout: 30_000 }); - - // Grouping persists, so the run must leave the sidebar back in project mode. - await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS); - await expect(projectList).toBeVisible({ timeout: 30_000 }); - await expect(statusList).toHaveCount(0); - } finally { - await seeded.cleanup().catch(() => undefined); - } - }); -}); diff --git a/packages/app/e2e/browser/command-center-workspace-management.spec.ts b/packages/app/e2e/browser/command-center-workspace-management.spec.ts index 972386a889..693281c991 100644 --- a/packages/app/e2e/browser/command-center-workspace-management.spec.ts +++ b/packages/app/e2e/browser/command-center-workspace-management.spec.ts @@ -107,73 +107,122 @@ async function rejectNextLabelAssignment(page: Page): Promise { }); } +const GROUP_BY_STATUS = "Group by status"; +const GROUP_BY_PROJECT = "Group by project"; + +// Result rows carry no testID of their own, so the entries are addressed by their visible label. +async function runGroupingEntry(page: Page, label: string, absent: string): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("group"); + + const entry = panel.getByText(label, { exact: true }); + await expect(entry).toBeVisible({ timeout: 30_000 }); + // The entry always names the mode you are not in, so its opposite must never be listed. + await expect(panel.getByText(absent, { exact: true })).toHaveCount(0); + + await entry.click(); + await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 }); +} + test.describe("Command center workspace management", () => { - test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" }); - + test("manage the active workspace and scope its commands when leaving the route", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "cc-manage-" }); try { await gotoAppShell(page); await openWorkspace(page, workspace.workspaceId); - await collapseProjectSection(page, workspace); + await test.step("renames the active workspace while its sidebar row is unmounted", async () => { + await collapseProjectSection(page, workspace); - await runCommand(page, "rename", "Rename workspace"); + await runCommand(page, "rename", "Rename workspace"); - // Focused, not merely visible. A modal that mounts behind the closing palette, or loses the - // focus race with its focus-restore, still renders — you just cannot type into it. - const input = page.getByTestId("workspace-rename-modal-global-input"); - await expect(input).toBeFocused({ timeout: 15_000 }); - await expect(input).toHaveValue(workspace.workspaceName); + // Focused, not merely visible. A modal that mounts behind the closing palette, or loses the + // focus race with its focus-restore, still renders — you just cannot type into it. + const input = page.getByTestId("workspace-rename-modal-global-input"); + await expect(input).toBeFocused({ timeout: 15_000 }); + await expect(input).toHaveValue(workspace.workspaceName); - const customTitle = "Renamed From Palette"; - await input.fill(customTitle); - await page.getByTestId("workspace-rename-modal-global-submit").click(); - await expect(input).toHaveCount(0, { timeout: 15_000 }); + const customTitle = "Renamed From Palette"; + await input.fill(customTitle); + await page.getByTestId("workspace-rename-modal-global-submit").click(); + await expect(input).toHaveCount(0, { timeout: 15_000 }); - // Re-expand the section to read the row back. - const header = page - .locator('[data-testid^="sidebar-project-row-"]') - .filter({ hasText: workspace.projectDisplayName }); - await header.click(); - await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, { - timeout: 15_000, + // Re-expand the section to read the row back. + const header = page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: workspace.projectDisplayName }); + await header.click(); + await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, { + timeout: 15_000, + }); }); - } finally { - await workspace.cleanup(); - } - }); + await test.step("copies the workspace path and confirms with a toast", async () => { + await runCommand(page, "copy path", "Copy workspace path"); - test("copies the workspace path and confirms with a toast", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" }); - - try { - await gotoAppShell(page); - await openWorkspace(page, workspace.workspaceId); - - await runCommand(page, "copy path", "Copy workspace path"); - - // toast.copied wraps its label: "Copied {{label}}" with label "Path copied". - await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({ - timeout: 15_000, + // toast.copied wraps its label: "Copied {{label}}" with label "Path copied". + await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({ + timeout: 15_000, + }); }); - } finally { - await workspace.cleanup(); - } - }); + await test.step("flips the pin entry to Unpin after pinning from the palette", async () => { + await runCommand(page, "pin", "Pin to top"); + await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 }); - test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" }); + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("pin"); + await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 }); + await expect(action(panel, "Pin to top")).toHaveCount(0); - try { - await gotoAppShell(page); - await openWorkspace(page, workspace.workspaceId); + await action(panel, "Unpin").click(); + await expect(page.getByTestId("sidebar-pinned-section")).toHaveCount(0); + }); + await test.step("grouping commands persist across reload and switch back", async () => { + const projectList = page.getByTestId("sidebar-project-workspace-list-scroll"); + const statusList = page.getByTestId("sidebar-status-list-scroll"); + await expect(projectList).toBeVisible({ timeout: 30_000 }); + // Query-only: the grouping entry stays out of the default empty-query list. + const panel = await openCommandCenter(page); + await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0); + await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(panel).not.toBeVisible({ timeout: 30_000 }); - await runCommand(page, "pin", "Pin to top"); - await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 }); + await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT); + await expect(statusList).toBeVisible({ timeout: 30_000 }); + await expect(projectList).toHaveCount(0); - const panel = await openCommandCenter(page); - await panel.getByTestId("command-center-input").fill("pin"); - await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 }); - await expect(action(panel, "Pin to top")).toHaveCount(0); + // The only assertion that catches a flip which never reached persisted storage. + await page.reload(); + await expect(statusList).toBeVisible({ timeout: 30_000 }); + + // Grouping persists, so the run must leave the sidebar back in project mode. + await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS); + await expect(projectList).toBeVisible({ timeout: 30_000 }); + await expect(statusList).toHaveCount(0); + }); + await test.step("lists only the globally-handled toggle off a workspace route", async () => { + // All three are listed while a workspace route owns the handlers. + const workspacePanel = await openCommandCenter(page); + await workspacePanel.getByTestId("command-center-input").fill("toggle"); + await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ + timeout: 15_000, + }); + await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible(); + await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("command-center-panel")).toHaveCount(0); + + await page.locator('[data-testid="sidebar-settings"]:visible').first().click(); + await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 }); + + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("toggle"); + + await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 }); + await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0); + await expect(action(panel, "Toggle focus mode")).toHaveCount(0); + }); } finally { await workspace.cleanup(); } @@ -183,35 +232,6 @@ test.describe("Command center workspace management", () => { // handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them // off a workspace route would give the user two entries that close the palette and do nothing. // Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed. - test("lists only the globally-handled toggle off a workspace route", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" }); - - try { - await gotoAppShell(page); - await openWorkspace(page, workspace.workspaceId); - - // All three are listed while a workspace route owns the handlers. - const workspacePanel = await openCommandCenter(page); - await workspacePanel.getByTestId("command-center-input").fill("toggle"); - await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 }); - await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible(); - await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(page.getByTestId("command-center-panel")).toHaveCount(0); - - await page.locator('[data-testid="sidebar-settings"]:visible').first().click(); - await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 }); - - const panel = await openCommandCenter(page); - await panel.getByTestId("command-center-input").fill("toggle"); - - await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 }); - await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0); - await expect(action(panel, "Toggle focus mode")).toHaveCount(0); - } finally { - await workspace.cleanup(); - } - }); test("toggles a label on the active workspace from the palette", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "cc-label-" }); diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts index 4b01b0c67d..9c25c2c786 100644 --- a/packages/app/e2e/browser/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -44,6 +44,16 @@ test.describe("Command center workspaces", () => { }); await gotoAppShell(page); + await test.step("single-host results show project and branch without a host label", async () => { + const singleHostPanel = await openCommandCenter(page); + const singleHostRow = singleHostPanel.getByTestId( + `command-center-workspace-${getServerId()}:${seeded.workspaceId}`, + ); + await expect(singleHostRow.getByTestId("command-center-workspace-subtitle")).toHaveText( + `${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`, + ); + await page.keyboard.press("Escape"); + }); await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host", @@ -121,38 +131,4 @@ test.describe("Command center workspaces", () => { await seeded.cleanup(); } }); - - test("single-host workspace subtitle omits the host and shows project · branch", async ({ - page, - }) => { - const seeded = await seedWorkspace({ - repoPrefix: "command-center-workspace-single-", - title: WORKSPACE_TITLE, - }); - - try { - execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], { - cwd: seeded.repoPath, - stdio: "ignore", - }); - const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath); - if (!refreshed.success) { - throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`); - } - - // No secondary host: with a single host, the host label is gated away. - await gotoAppShell(page); - - const panel = await openCommandCenter(page); - const row = panel.getByTestId( - `command-center-workspace-${getServerId()}:${seeded.workspaceId}`, - ); - await expect(row).toBeVisible({ timeout: 30_000 }); - - const subtitle = row.getByTestId("command-center-workspace-subtitle"); - await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`); - } finally { - await seeded.cleanup(); - } - }); }); diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index 906efcfb42..f513fb1f84 100644 --- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -6,20 +6,14 @@ import { } from "../support/helpers/composer"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; -import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { delayBrowserWorkspaceCreatedResponse, expectNewWorkspaceDraft, - expectNewWorkspaceProjectSelected, fillNewWorkspaceDraft, openGlobalNewWorkspaceComposer, openNewWorkspaceComposer, - selectNewWorkspaceHost, - selectNewWorkspaceProject, } from "../support/helpers/new-workspace"; -import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; -import { seedSavedSettingsHosts } from "../support/helpers/settings"; +import { seedWorkspace } from "../support/helpers/seed-client"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const DRAFT = `Please investigate the workspace startup failure. @@ -29,70 +23,6 @@ Trace the request from the app through the daemon, preserve the existing behavio test.describe("New workspace composer draft", () => { test.describe.configure({ timeout: 240_000 }); - test("keeps the draft when the project changes", async ({ page }) => { - const firstProject: SeededWorkspace = await seedWorkspace({ - repoPrefix: "new-workspace-draft-project-a-", - }); - const secondProject: SeededWorkspace = await seedWorkspace({ - repoPrefix: "new-workspace-draft-project-b-", - }); - - try { - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openNewWorkspaceComposer(page, { - projectKey: firstProject.projectKey, - projectDisplayName: firstProject.projectDisplayName, - }); - await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName); - - await fillNewWorkspaceDraft(page, DRAFT); - - await selectNewWorkspaceProject(page, { - projectKey: secondProject.projectKey, - projectDisplayName: secondProject.projectDisplayName, - }); - - await expectNewWorkspaceDraft(page, DRAFT); - } finally { - await secondProject.cleanup(); - await firstProject.cleanup(); - } - }); - - test("keeps the draft when the host changes", async ({ page }) => { - const project: SeededWorkspace = await seedWorkspace({ - repoPrefix: "new-workspace-draft-host-", - }); - const secondaryServerId = "new-workspace-draft-secondary-host"; - - try { - await seedSavedSettingsHosts(page, [ - { - serverId: getServerId(), - label: "Primary host", - endpoint: `127.0.0.1:${getE2EDaemonPort()}`, - }, - { - serverId: secondaryServerId, - label: "Secondary host", - endpoint: "127.0.0.1:9", - }, - ]); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openGlobalNewWorkspaceComposer(page); - - await fillNewWorkspaceDraft(page, DRAFT); - await selectNewWorkspaceHost(page, "Secondary host"); - - await expectNewWorkspaceDraft(page, DRAFT); - } finally { - await project.cleanup(); - } - }); - test("does not restore a submitted draft after deferred publication", async ({ page }) => { const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" }); diff --git a/packages/app/e2e/browser/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts index e6c788a8f4..e4e393e200 100644 --- a/packages/app/e2e/browser/new-workspace-entry.spec.ts +++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts @@ -3,6 +3,9 @@ import { gotoAppShell } from "../support/helpers/app"; import { connectNewWorkspaceDaemonClient, expectNewWorkspaceControlsEnabled, + expectNewWorkspaceDraft, + fillNewWorkspaceDraft, + selectNewWorkspaceHost, expectNewWorkspaceProjectSelected, expectNewWorkspaceTriggerLabelsAligned, openGlobalNewWorkspaceComposer, @@ -90,6 +93,11 @@ test.describe("New workspace entry points", () => { projectLabel: seeded.projectDisplayName, hostLabel: "localhost", }); + await test.step("changing hosts preserves the draft", async () => { + await fillNewWorkspaceDraft(page, "Keep this draft while choosing a host"); + await selectNewWorkspaceHost(page, "Secondary host"); + await expectNewWorkspaceDraft(page, "Keep this draft while choosing a host"); + }); } finally { await seeded.cleanup(); } @@ -121,6 +129,9 @@ test.describe("New workspace entry points", () => { timeout: 30_000, }); await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0); + await test.step("Ctrl+P opens the project picker with search focused", async () => { + await openNewWorkspaceProjectPickerWithShortcut(page); + }); } finally { await seeded.cleanup(); } @@ -137,20 +148,6 @@ test.describe("New workspace entry points", () => { await expectNewWorkspaceControlsEnabled(page); }); - test("Ctrl+P opens the project picker with search focused", async ({ page }) => { - const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); - - try { - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openGlobalNewWorkspaceComposer(page); - - await openNewWorkspaceProjectPickerWithShortcut(page); - } finally { - await seeded.cleanup(); - } - }); - test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({ page, }) => { @@ -220,6 +217,8 @@ test.describe("New workspace entry points", () => { }); await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); + await fillNewWorkspaceDraft(page, "Keep this draft while choosing a project"); + // Manually override the selection to C from inside A's screen. This stale // manualProjectKey is what the reused 'new' screen must reset when the next // route-driven navigation targets a different project. @@ -230,6 +229,9 @@ test.describe("New workspace entry points", () => { await expect(optionC).toBeVisible({ timeout: 30_000 }); await optionC.click(); await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName); + await test.step("changing projects preserves the draft", async () => { + await expectNewWorkspaceDraft(page, "Keep this draft while choosing a project"); + }); // Navigate via B's row icon. B must be preselected — the route project wins // because the stale manual choice (C) was reset on the route change. If the diff --git a/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts b/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts deleted file mode 100644 index 7ec3fd4554..0000000000 --- a/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; -import { - archiveLocalWorkspaceFromDaemon, - archiveWorkspaceFromDaemon, - assertNewWorkspaceSidebarAndHeader, - connectNewWorkspaceDaemonClient, - expectWorkspaceIsolationSelected, - openNewWorkspaceComposer, - openProjectViaDaemon, - openStartingRefPicker, - selectBranchInPicker, -} from "../support/helpers/new-workspace"; -import { expectNoTruncation } from "../support/helpers/no-truncation"; -import { createTempGitRepo } from "../support/helpers/workspace"; -import { getServerId } from "../support/helpers/server-id"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; - -// Regression for "the local / worktree selection in the new workspace is not -// remembered." The isolation choice persists in the create-form preferences -// (FormPreferences.isolation), so it must survive the create→reopen remount: -// creating a worktree workspace navigates away from /new and unmounts it, and -// reopening New Workspace has to still show "New worktree". -test.describe("New workspace isolation memory", () => { - let client: Awaited>; - const localWorkspaceIds = new Set(); - const createdWorktreeDirectories = new Set(); - - test.describe.configure({ timeout: 240_000 }); - - test.beforeEach(async () => { - client = await connectNewWorkspaceDaemonClient(); - }); - - test.afterEach(async () => { - if (client) { - for (const workspaceDirectory of createdWorktreeDirectories) { - await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined); - } - for (const workspaceId of localWorkspaceIds) { - await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); - } - } - createdWorktreeDirectories.clear(); - localWorkspaceIds.clear(); - await client?.close().catch(() => undefined); - }); - - test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => { - const serverId = getServerId(); - const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] }); - - try { - const openedProject = await openProjectViaDaemon(client, tempRepo.path); - localWorkspaceIds.add(openedProject.workspaceId); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - - // First visit: the screen opens on Local, switch it to New worktree and create. - await openNewWorkspaceComposer(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, - }); - await expectWorkspaceIsolationSelected(page, "local"); - await page.getByTestId("workspace-create-isolation-trigger").click(); - const isolationPopup = page.getByTestId("combobox-desktop-container").last(); - await expect(isolationPopup).toBeVisible({ timeout: 30_000 }); - await expectNoTruncation(isolationPopup); - await page.getByTestId("workspace-create-isolation-worktree").click(); - await expectWorkspaceIsolationSelected(page, "worktree"); - - await openStartingRefPicker(page); - await selectBranchInPicker(page, "dev"); - - const createButton = page - .getByTestId("message-input-root") - .getByRole("button", { name: "Create" }); - await expect(createButton).toBeVisible({ timeout: 30_000 }); - await createButton.click(); - - const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { - serverId, - client, - previousWorkspaceId: openedProject.workspaceId, - projectDisplayName: openedProject.projectDisplayName, - }); - createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); - - // Second visit (fresh mount of /new): the worktree choice must stick. - await openNewWorkspaceComposer(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, - }); - await expectWorkspaceIsolationSelected(page, "worktree"); - } finally { - await tempRepo.cleanup(); - } - }); -}); diff --git a/packages/app/e2e/browser/new-workspace-launch-composer.spec.ts b/packages/app/e2e/browser/new-workspace-launch-composer.spec.ts deleted file mode 100644 index f812d93ac1..0000000000 --- a/packages/app/e2e/browser/new-workspace-launch-composer.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { - expectNewWorkspaceDraft, - fillNewWorkspaceDraft, - openNewWorkspaceComposer, -} from "../support/helpers/new-workspace"; -import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; -import { - attachButton, - expectChatComposerActive, - expectTerminalComposerActive, - fillTerminalPrompt, - seedTerminalProfiles, - selectChatLaunch, - selectLaunchOption, - terminalPromptInput, - type TerminalProfile, - type TerminalProfileSeed, -} from "../support/helpers/new-workspace-launch"; - -const PROFILE: TerminalProfile = { - id: "e2e-composer-profile", - name: "Composer Profile", - command: "/bin/echo", - args: ["{{{prompt}}}"], -}; - -test.describe("New workspace: the composer is one control in two modes", () => { - let workspace: SeededWorkspace; - let profileSeed: TerminalProfileSeed; - - test.beforeEach(async () => { - workspace = await seedWorkspace({ repoPrefix: "launch-composer-" }); - profileSeed = await seedTerminalProfiles([PROFILE]); - }); - - test.afterEach(async () => { - await profileSeed.restore(); - await workspace?.cleanup(); - }); - - test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({ - page, - }) => { - test.setTimeout(60_000); - await gotoAppShell(page); - await waitForSidebarHydration(page); - - await openNewWorkspaceComposer(page, { - projectKey: workspace.projectKey, - projectDisplayName: workspace.projectDisplayName, - }); - - await test.step("a chat draft does not leak into the terminal prompt", async () => { - await fillNewWorkspaceDraft(page, "a chat-only draft"); - await selectLaunchOption(page, PROFILE.id); - await expectTerminalComposerActive(page); - await expect(terminalPromptInput(page)).toHaveValue(""); - }); - - await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => { - await expect(attachButton(page)).toHaveCount(0); - await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0); - await expect( - page.locator('[data-testid="message-input-root"]:visible').getByRole("button", { - name: "Launch", - }), - ).toBeVisible(); - }); - - await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => { - await fillTerminalPrompt(page, "a terminal-only draft"); - await selectChatLaunch(page); - await expectChatComposerActive(page); - await expectNewWorkspaceDraft(page, "a chat-only draft"); - }); - - await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => { - await selectLaunchOption(page, PROFILE.id); - await expectTerminalComposerActive(page); - await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft"); - }); - }); -}); diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts deleted file mode 100644 index f2ee89c12f..0000000000 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { - openNewWorkspaceComposer, - submitNewWorkspacePrompt, -} from "../support/helpers/new-workspace"; -import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; -import { - expectChatLaunchSelected, - expectTerminalLaunchSelected, - expectTerminalOutputContains, - expectWorkspaceOpensWithTerminalTab, - fillTerminalPrompt, - patchTerminalProfiles, - seedTerminalProfiles, - selectChatLaunch, - selectLaunchOption, - submitTerminalLaunch, - type TerminalProfile, - type TerminalProfileSeed, -} from "../support/helpers/new-workspace-launch"; - -// Wait for input until project cleanup kills the terminal. A fixed lifetime -// can expire before a busy browser attaches or finishes revisiting the composer. -const PROMPT_PROFILE: TerminalProfile = { - id: "e2e-memory-profile", - name: "Memory Profile", - command: "/bin/sh", - args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"], -}; - -function hasLeftNewWorkspaceRoute(url: URL): boolean { - return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`); -} - -test.describe("New workspace: launch target memory", () => { - let workspace: SeededWorkspace; - let profileSeed: TerminalProfileSeed; - - test.beforeEach(async () => { - workspace = await seedWorkspace({ repoPrefix: "launch-memory-" }); - profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]); - }); - - test.afterEach(async () => { - await profileSeed.restore(); - await workspace?.cleanup(); - }); - - test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({ - page, - }) => { - test.setTimeout(90_000); - await gotoAppShell(page); - await waitForSidebarHydration(page); - - const openNewWorkspace = () => - openNewWorkspaceComposer(page, { - projectKey: workspace.projectKey, - projectDisplayName: workspace.projectDisplayName, - }); - - await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => { - await openNewWorkspace(); - await selectLaunchOption(page, PROMPT_PROFILE.id); - await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); - await fillTerminalPrompt(page, "remember me"); - await submitTerminalLaunch(page); - await expectWorkspaceOpensWithTerminalTab(page); - await expectTerminalOutputContains(page, "remembered: remember me"); - - await openNewWorkspace(); - await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); - }); - - await test.step("switching to Chat and submitting preselects Chat the next time", async () => { - await selectChatLaunch(page); - await expectChatLaunchSelected(page); - await submitNewWorkspacePrompt(page, "hello from chat"); - // The chat handoff navigates asynchronously (draft -> created agent). Let - // it fully settle before moving on, so it can't fire a late redirect - // that races the next step's navigation. - await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 }); - - await openNewWorkspace(); - await expectChatLaunchSelected(page); - }); - - await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => { - await selectLaunchOption(page, PROMPT_PROFILE.id); - await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); - await fillTerminalPrompt(page, "second run"); - await submitTerminalLaunch(page); - await expectWorkspaceOpensWithTerminalTab(page); - - await patchTerminalProfiles([]); - - await openNewWorkspace(); - await expectChatLaunchSelected(page); - }); - }); -}); diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index d393966ee8..b41a8bbafa 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -5,6 +5,7 @@ import { expectNewWorkspaceDraft, fillNewWorkspaceDraft, openNewWorkspaceComposer, + submitNewWorkspacePrompt, } from "../support/helpers/new-workspace"; import { composerLocator, @@ -15,6 +16,12 @@ import { } from "../support/helpers/composer"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { + attachButton, + expectChatComposerActive, + expectTerminalComposerActive, + expectChatLaunchSelected, + patchTerminalProfiles, + selectChatLaunch, expectTerminalLaunchSelected, expectTerminalOutputContains, expectTerminalPreviewCommand, @@ -67,21 +74,50 @@ test.describe("New workspace: launching a terminal", () => { await workspace?.cleanup(); }); - test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({ + test("switch launch modes, run prompt and bare profiles, and remember successful targets", async ({ page, }) => { test.setTimeout(90_000); await gotoAppShell(page); await waitForSidebarHydration(page); - await test.step("pick a profile that takes a prompt, type one, and submit", async () => { + await test.step("open New workspace to choose a launch target", async () => { await openNewWorkspaceComposer(page, { projectKey: workspace.projectKey, projectDisplayName: workspace.projectDisplayName, }); + }); + await test.step("a chat draft does not leak into the terminal prompt", async () => { + await fillNewWorkspaceDraft(page, "a chat-only draft"); await selectLaunchOption(page, PROMPT_PROFILE.id); - await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); + await expectTerminalComposerActive(page); + await expect(terminalPromptInput(page)).toHaveValue(""); + }); + await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => { + await expect(attachButton(page)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0); + await expect( + page.locator('[data-testid="message-input-root"]:visible').getByRole("button", { + name: "Launch", + }), + ).toBeVisible(); + }); + + await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => { + await fillTerminalPrompt(page, "a terminal-only draft"); + await selectChatLaunch(page); + await expectChatComposerActive(page); + await expectNewWorkspaceDraft(page, "a chat-only draft"); + }); + + await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => { + await selectLaunchOption(page, PROMPT_PROFILE.id); + await expectTerminalComposerActive(page); + await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft"); + }); + await test.step("submit the prompt profile", async () => { + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); await fillTerminalPrompt(page, "fix the flaky test"); await submitTerminalLaunch(page); }); @@ -96,6 +132,7 @@ test.describe("New workspace: launching a terminal", () => { projectKey: workspace.projectKey, projectDisplayName: workspace.projectDisplayName, }); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); await selectLaunchOption(page, BARE_PROFILE.id); await expect(terminalPromptInput(page)).toHaveCount(0); await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat"); @@ -107,6 +144,31 @@ test.describe("New workspace: launching a terminal", () => { await expectWorkspaceOpensWithTerminalTab(page); await expectTerminalOutputContains(page, "bare-launch-static-line"); }); + await test.step("successful launches remember their target across visits", async () => { + await openNewWorkspaceComposer(page, workspace); + await expectTerminalLaunchSelected(page, BARE_PROFILE.name); + await selectChatLaunch(page); + await expectChatLaunchSelected(page); + await submitNewWorkspacePrompt(page, "hello from chat"); + await page.waitForURL(hasLeftNewWorkspaceRoute, { + timeout: 30_000, + }); + await openNewWorkspaceComposer(page, workspace); + await expectChatLaunchSelected(page); + }); + await test.step("a removed remembered terminal profile falls back to Chat", async () => { + await selectLaunchOption(page, PROMPT_PROFILE.id); + await fillTerminalPrompt(page, "second run"); + await submitTerminalLaunch(page); + await expectWorkspaceOpensWithTerminalTab(page); + try { + await patchTerminalProfiles([]); + await openNewWorkspaceComposer(page, workspace); + await expectChatLaunchSelected(page); + } finally { + await patchTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]); + } + }); }); test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({ @@ -157,6 +219,10 @@ test.describe("New workspace: launching a terminal", () => { }); }); +function hasLeftNewWorkspaceRoute(url: URL): boolean { + return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`); +} + async function expectNewWorkspaceHeaderHidden(page: Page): Promise { await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0); } diff --git a/packages/app/e2e/browser/new-workspace-preselect.spec.ts b/packages/app/e2e/browser/new-workspace-preselect.spec.ts index 88d6cce775..ad4029caba 100644 --- a/packages/app/e2e/browser/new-workspace-preselect.spec.ts +++ b/packages/app/e2e/browser/new-workspace-preselect.spec.ts @@ -172,7 +172,9 @@ test.describe("New workspace preselects the open workspace's project", () => { await projectB?.cleanup(); }); - test("Cmd+N preselects the project you are looking at", async ({ page }) => { + test("workspace button and shortcut follow the active project across visits", async ({ + page, + }) => { await gotoAppShell(page); await waitForSidebarHydration(page); @@ -183,6 +185,15 @@ test.describe("New workspace preselects the open workspace's project", () => { }); await pressNewWorkspaceShortcut(page); await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: projectB.workspaceId, + }); + await test.step("global button follows project B", async () => { + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); + }); await switchWorkspaceViaSidebar({ page, @@ -191,27 +202,15 @@ test.describe("New workspace preselects the open workspace's project", () => { }); await pressNewWorkspaceShortcut(page); await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); - }); - - test("New workspace button preselects the project you are looking at", async ({ page }) => { - await gotoAppShell(page); - await waitForSidebarHydration(page); - - await switchWorkspaceViaSidebar({ - page, - serverId: getServerId(), - workspaceId: projectB.workspaceId, - }); - await openGlobalNewWorkspaceComposer(page); - await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); - await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: projectA.workspaceId, }); - await openGlobalNewWorkspaceComposer(page); - await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); + await test.step("global button follows project A", async () => { + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); + }); }); test("Cmd+N preselects the connected host project when an offline saved host is first", async ({ diff --git a/packages/app/e2e/browser/new-workspace.spec.ts b/packages/app/e2e/browser/new-workspace.spec.ts index 7206c9f52e..6f98e0883f 100644 --- a/packages/app/e2e/browser/new-workspace.spec.ts +++ b/packages/app/e2e/browser/new-workspace.spec.ts @@ -1,3 +1,4 @@ +import { expectNoTruncation } from "../support/helpers/no-truncation"; import { existsSync } from "node:fs"; import path from "node:path"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; @@ -33,6 +34,7 @@ import { selectGitHubPrInPicker, selectPickerOptionByKeyboard, selectWorkspaceIsolation, + expectWorkspaceIsolationSelected, submitNewWorkspacePrompt, } from "../support/helpers/new-workspace"; import { @@ -723,9 +725,26 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); - await openStartingRefPicker(page); - await selectBranchInPicker(page, "dev"); + await expectWorkspaceIsolationSelected(page, "local"); + await page.getByTestId("workspace-create-isolation-trigger").click(); + const isolationPopup = page.getByTestId("combobox-desktop-container").last(); + await expect(isolationPopup).toBeVisible({ timeout: 30_000 }); + await expectNoTruncation(isolationPopup); + await page.getByTestId("workspace-create-isolation-worktree").click(); + await expectWorkspaceIsolationSelected(page, "worktree"); + await test.step("cancel the branch picker without changing the selection", async () => { + await openBranchPicker(page); + await expectPickerOpen(page); + await closeBranchPicker(page); + await expectPickerClosed(page); + }); + await test.step("select the filtered starting branch with the keyboard", async () => { + await openBranchPicker(page); + await expectPickerOpen(page); + await selectPickerOptionByKeyboard(page, "dev"); + await expectPickerSelected(page, "dev"); + await expectPickerClosed(page); + }); const createButton = page .getByTestId("message-input-root") @@ -749,6 +768,10 @@ test.describe("New workspace flow", () => { expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory)); expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true); expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true); + await test.step("the worktree choice survives creating and reopening New workspace", async () => { + await openNewWorkspaceComposer(page, openedProject); + await expectWorkspaceIsolationSelected(page, "worktree"); + }); } finally { await tempRepo.cleanup(); } @@ -890,57 +913,6 @@ test.describe("New workspace flow", () => { }); }); - test("branch picker opens via keyboard and selects the filtered option on Enter", async ({ - page, - }) => { - const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] }); - - try { - const openedProject = await openProjectViaDaemon(client, tempRepo.path); - localWorkspaceIds.add(openedProject.workspaceId); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openNewWorkspaceComposer(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, - }); - await selectWorkspaceIsolation(page, "worktree"); - - await openBranchPicker(page); - await expectPickerOpen(page); - await selectPickerOptionByKeyboard(page, "dev"); - await expectPickerSelected(page, "dev"); - await expectPickerClosed(page); - } finally { - await tempRepo.cleanup(); - } - }); - - test("branch picker closes on Escape without selecting an option", async ({ page }) => { - const tempRepo = await createTempGitRepo("picker-escape-"); - - try { - const openedProject = await openProjectViaDaemon(client, tempRepo.path); - localWorkspaceIds.add(openedProject.workspaceId); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openNewWorkspaceComposer(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, - }); - await selectWorkspaceIsolation(page, "worktree"); - - await openBranchPicker(page); - await expectPickerOpen(page); - await closeBranchPicker(page); - await expectPickerClosed(page); - } finally { - await tempRepo.cleanup(); - } - }); - test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => { test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)"); diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 4a866a8f44..7d5fc7cad9 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -749,7 +749,6 @@ function observePluginReload(page: Page) { return { waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1), waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1), - requestedAt: () => requests[0]!, requestCount: () => requests.length, }; } @@ -814,12 +813,6 @@ async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string }); } -/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */ -async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise { - const remaining = 66_000 - (Date.now() - requestedAt); - if (remaining > 0) await page.waitForTimeout(remaining); -} - test("keeps Reload pending past a minute while the plugin queue is busy", async ({ page, pluginEnvironment, @@ -833,6 +826,9 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async "Plugin v1", ); + // Advance the real browser client's RPC timers while the daemon's build gate + // remains held on its own clock. + await page.clock.install(); // Observe before the app loads: its daemon socket comes up with the page. const reload = observePluginReload(page); await gotoAppShell(page); @@ -851,7 +847,7 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async await expectPluginRowPending(page, "e2e-plugin"); await capturePluginSurface(page, testInfo, "plugin-reload-pending.png"); - await waitPastFormerReloadDeadline(page, reload.requestedAt()); + await page.clock.fastForward(66_000); // Past the former deadline the reload is still pending, the row shows its previous state, and // no timeout error reached the surface. diff --git a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts index 5ef70cca61..3211316efb 100644 --- a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -306,13 +306,17 @@ test.describe("Viewed agent timelines", () => { }) => { test.setTimeout(150_000); const subscriptions = observeTimelineSubscriptions(page); - const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" }); + const scenario = await seedViewedTimelineScenario({ firstAgentModel: "ten-second-stream" }); try { await openAgent(page, scenario, scenario.firstAgentId); await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden."); await selectAgent(page, "Second viewed chat"); await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]); - const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000); + const agents = await scenario.client.fetchAgents(); + expect( + agents.entries.find((entry) => entry.agent.id === scenario.firstAgentId)?.agent.status, + ).toBe("running"); + const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000); expect(finish.status).toBe("idle"); await selectAgent(page, "First viewed chat"); diff --git a/packages/app/e2e/support/fixtures/controlled-stream-plugin/index.server.ts b/packages/app/e2e/support/fixtures/controlled-stream-plugin/index.server.ts new file mode 100644 index 0000000000..f56c23aab1 --- /dev/null +++ b/packages/app/e2e/support/fixtures/controlled-stream-plugin/index.server.ts @@ -0,0 +1,79 @@ +import { defineRpc } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider, type AcpStreamMessage } from "@getpaseo/plugin/server/acp"; +import { z } from "zod"; + +/** A single-turn producer controlled through the daemon, before timeline persistence. */ +export default function contribute(server: PluginServerContext) { + let startTurn!: (append: (chunks: string[], finish: boolean) => void) => void; + const turn = new Promise<(chunks: string[], finish: boolean) => void>((resolve) => { + startTurn = resolve; + }); + server.handle( + defineRpc({ + name: "append", + input: z.object({ chunks: z.array(z.string()), finish: z.boolean() }), + output: z.null(), + }), + async ({ chunks, finish }) => { + const append = await turn; + append(chunks, finish); + return null; + }, + ); + server.registerProvider( + runAcpProvider({ id: "controlled-stream", label: "Controlled stream", connector: connect }), + ); + return () => {}; + + function connect() { + let controller: ReadableStreamDefaultController; + return { + readable: new ReadableStream({ + start(value) { + controller = value; + }, + }), + writable: new WritableStream({ + write(message) { + if (!("method" in message) || !("id" in message)) return; + if (message.method === "session/prompt") { + let finished = false; + startTurn((chunks, finish) => { + if (finished) return; + for (const text of chunks) { + controller.enqueue({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "controlled", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + }, + }); + } + if (finish) { + finished = true; + controller.enqueue({ + jsonrpc: "2.0", + id: message.id, + result: { stopReason: "end_turn" }, + }); + } + }); + return; + } + let result: unknown = {}; + if (message.method === "initialize") { + result = { protocolVersion: 1, agentCapabilities: {} }; + } else if (message.method === "session/new") { + result = { sessionId: "controlled" }; + } + controller.enqueue({ jsonrpc: "2.0", id: message.id, result }); + }, + }), + }; + } +} diff --git a/packages/app/e2e/support/fixtures/controlled-stream-plugin/paseo-plugin.json b/packages/app/e2e/support/fixtures/controlled-stream-plugin/paseo-plugin.json new file mode 100644 index 0000000000..64341c35b0 --- /dev/null +++ b/packages/app/e2e/support/fixtures/controlled-stream-plugin/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "controlled-stream-test", "requirements": { "paseo": ">=0.8.0" } } diff --git a/packages/app/e2e/support/helpers/scroll-jumps.test.ts b/packages/app/e2e/support/helpers/scroll-jumps.test.ts new file mode 100644 index 0000000000..5664452b42 --- /dev/null +++ b/packages/app/e2e/support/helpers/scroll-jumps.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "vitest"; +import { findScrollJumps, type ScrollFrame } from "./scroll-jumps"; + +test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => { + const before: ScrollFrame = { + at: 1000, + scrollTop: 1000, + scrollHeight: 5000, + viewportHeight: 800, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -150, height: 150 }, + { id: "reading", top: 0, height: 100 }, + ], + anchor: "reading", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 0, + unmounted: 0, + }; + const after: ScrollFrame = { + ...before, + at: 1400, + scrollTop: 900, + wheelTotal: 100, + lastWheelAt: 1050, + rows: [ + { id: "image", top: -50, height: 1150 }, + { id: "reading", top: 1100, height: 100 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + expect( + findScrollJumps([ + { ...before, scrollTop: 200 }, + { + ...after, + wheelTotal: 1000, + scrollTop: 0, + rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })), + }, + ]), + ).toEqual([]); + expect( + findScrollJumps([ + before, + { ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) }, + ]), + ).toHaveLength(1); + expect( + findScrollJumps([ + { ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] }, + { ...after, rows: [{ id: "image", top: 600, height: 1150 }] }, + ]), + ).toHaveLength(1); +}); + +test("scroll detector accounts for delayed wheel input and growth below the new reader", () => { + const before: ScrollFrame = { + at: 1000, + scrollTop: 2000, + scrollHeight: 5000, + viewportHeight: 800, + virtualized: true, + loading: false, + rows: [ + { id: "new-reader", top: -520, height: 278 }, + { id: "image", top: -242, height: 184 }, + { id: "old-reader", top: -58, height: 130 }, + ], + anchor: "old-reader", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 0, + unmounted: 0, + }; + const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 }; + const after: ScrollFrame = { + ...wheel, + at: 1220, + scrollTop: 1520, + scrollHeight: 5041, + anchor: "new-reader", + rows: [ + { id: "new-reader", top: -40, height: 278 }, + { id: "image", top: 238, height: 225 }, + { id: "old-reader", top: 463, height: 130 }, + ], + }; + expect(findScrollJumps([before, wheel, after])).toEqual([]); + // The previous 480 px input has already moved the viewport; the recent + // budget includes it, but only the next 480 px belongs to this frame. + const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 }; + const steadyAfter = { + ...after, + at: 1100, + wheelTotal: 960, + lastWheelAt: 1099, + scrollHeight: 6000, + rows: [ + { id: "new-reader", top: -40, height: 278 }, + { id: "image", top: 238, height: 1184 }, + { id: "old-reader", top: 1422, height: 130 }, + ], + }; + expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]); + + expect( + findScrollJumps([ + before, + wheel, + { + ...after, + rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), + }, + ]), + ).toHaveLength(1); +}); + +test("scroll detector follows the entered image through resize and virtual offset correction", () => { + for (const height of [225, 1632]) { + const before: ScrollFrame = { + at: 1000, + scrollTop: 2208, + scrollHeight: 5000, + viewportHeight: 516, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -704, height: 560 }, + { id: "caption", top: -144, height: 100 }, + { id: "old-reader", top: -44, height: 127 }, + ], + anchor: "old-reader", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 3, + unmounted: 0, + }; + const resize = height - 560; + const after: ScrollFrame = { + ...before, + at: 1060, + // The virtualizer corrects 110px of estimates while the wheel moves 160px. + scrollTop: 1938, + scrollHeight: 5000 + resize - 110, + wheelTotal: 160, + lastWheelAt: 1020, + imageLoads: 1, + rows: [ + { id: "image", top: -544, height }, + { id: "caption", top: 16 + resize, height: 100 }, + { id: "old-reader", top: 116 + resize, height: 127 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + // Resizing must not excuse additional viewport movement. + expect( + findScrollJumps([ + before, + { + ...after, + rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), + }, + ]), + ).toHaveLength(1); + } +}); diff --git a/packages/app/e2e/support/helpers/scroll-jumps.ts b/packages/app/e2e/support/helpers/scroll-jumps.ts new file mode 100644 index 0000000000..daee88e5cb --- /dev/null +++ b/packages/app/e2e/support/helpers/scroll-jumps.ts @@ -0,0 +1,95 @@ +interface RowFrame { + id: string; + top: number; + height: number; +} +export interface ScrollFrame { + at: number; + scrollTop: number; + scrollHeight: number; + viewportHeight: number; + virtualized: boolean; + loading: boolean; + rows: RowFrame[]; + anchor: string | null; + wheelTotal: number; + lastWheelAt: number; + inputFinishedAt: number | null; + imageLoads: number; + mounted: number; + unmounted: number; +} + +export function findScrollJumps(frames: ScrollFrame[]) { + return frames.flatMap((current, index) => { + const previous = frames[index - 1]; + if (!previous?.anchor) return []; + // Wheel input can move the reading line onto an image before it expands. + // Follow that intended row, not text now below the image. + const recent = frames.findLast((frame) => frame.at <= previous.at - 100); + const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); + const currentRows = new Map(current.rows.map((row) => [row.id, row])); + const anchor = previous.anchor; + const before = currentRows.has(anchor) + ? previous.rows.find((row) => row.id === anchor)! + : previous.rows + .filter((row) => currentRows.has(row.id)) + .sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0]; + if (!before) { + throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`); + } + const after = currentRows.get(before.id)!; + const movement = after.top - before.top; + // A busy main thread can deliver wheel movement hundreds of milliseconds + // after its event. Only assert idle stability after the driver stops input. + const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; + // scrollTop also includes virtualizer estimate corrections. Locate the row + // entered by wheel input from its viewport movement instead. Its old height + // identifies the reader before a simultaneous image resize takes effect. + const intendedRow = previous.rows.find((row) => { + const next = currentRows.get(row.id); + if (!next || idle || wheelBudget <= 0) return false; + const travel = next.top - row.top; + return ( + travel >= -8 && + travel <= Math.min(wheelBudget, previous.scrollTop) + 32 && + (next.top <= 8 || current.scrollTop === 0) && + next.top + row.height > 8 + ); + }); + const enteredRowResize = intendedRow + ? previous.rows.reduce((growth, row) => { + if (row.top < intendedRow.top || row.top >= before.top) return growth; + const height = currentRows.get(row.id)?.height ?? row.height; + return growth + height - row.height; + }, 0) + : 0; + const readingMovement = movement - enteredRowResize; + const excessForward = readingMovement > wheelBudget + 32; + // Upward wheel input moves the same text DOWN the viewport. A negative move + // is a reversal, independent of legitimate scrollTop compensation on prepend. + if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward) + return []; + return [ + { + frame: index, + at: current.at, + row: before.id, + movement, + idle, + excessForward, + resizedRows: current.rows.flatMap((row) => { + const old = previous.rows.find((candidate) => candidate.id === row.id); + return old && old.height !== row.height + ? [{ id: row.id, before: old.height, after: row.height }] + : []; + }), + loading: current.loading || previous.loading, + virtualized: current.virtualized, + heightChange: current.scrollHeight - previous.scrollHeight, + imageLoads: current.imageLoads - previous.imageLoads, + frameGapMs: current.at - previous.at, + }, + ]; + }); +} diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts index 41f8cf3279..0505c3dca6 100644 --- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -1,3 +1,4 @@ +import { findScrollJumps, type ScrollFrame } from "./scroll-jumps"; import { writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { expect, type Page, type TestInfo } from "@playwright/test"; @@ -12,27 +13,6 @@ export const scrollCadences = [ ] as const; type Cadence = (typeof scrollCadences)[number]; -interface RowFrame { - id: string; - top: number; - height: number; -} -export interface ScrollFrame { - at: number; - scrollTop: number; - scrollHeight: number; - viewportHeight: number; - virtualized: boolean; - loading: boolean; - rows: RowFrame[]; - anchor: string | null; - wheelTotal: number; - lastWheelAt: number; - inputFinishedAt: number | null; - imageLoads: number; - mounted: number; - unmounted: number; -} interface Recording { frames: ScrollFrame[]; finishInput(): void; @@ -404,80 +384,6 @@ export async function recordUpwardTraversal( }); } -export function findScrollJumps(frames: ScrollFrame[]) { - return frames.flatMap((current, index) => { - const previous = frames[index - 1]; - if (!previous?.anchor) return []; - // Wheel input can move the reading line onto an image before it expands. - // Follow that intended row, not text now below the image. - const recent = frames.findLast((frame) => frame.at <= previous.at - 100); - const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); - const currentRows = new Map(current.rows.map((row) => [row.id, row])); - const anchor = previous.anchor; - const before = currentRows.has(anchor) - ? previous.rows.find((row) => row.id === anchor)! - : previous.rows - .filter((row) => currentRows.has(row.id)) - .sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0]; - if (!before) { - throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`); - } - const after = currentRows.get(before.id)!; - const movement = after.top - before.top; - // A busy main thread can deliver wheel movement hundreds of milliseconds - // after its event. Only assert idle stability after the driver stops input. - const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; - // scrollTop also includes virtualizer estimate corrections. Locate the row - // entered by wheel input from its viewport movement instead. Its old height - // identifies the reader before a simultaneous image resize takes effect. - const intendedRow = previous.rows.find((row) => { - const next = currentRows.get(row.id); - if (!next || idle || wheelBudget <= 0) return false; - const travel = next.top - row.top; - return ( - travel >= -8 && - travel <= Math.min(wheelBudget, previous.scrollTop) + 32 && - (next.top <= 8 || current.scrollTop === 0) && - next.top + row.height > 8 - ); - }); - const enteredRowResize = intendedRow - ? previous.rows.reduce((growth, row) => { - if (row.top < intendedRow.top || row.top >= before.top) return growth; - const height = currentRows.get(row.id)?.height ?? row.height; - return growth + height - row.height; - }, 0) - : 0; - const readingMovement = movement - enteredRowResize; - const excessForward = readingMovement > wheelBudget + 32; - // Upward wheel input moves the same text DOWN the viewport. A negative move - // is a reversal, independent of legitimate scrollTop compensation on prepend. - if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward) - return []; - return [ - { - frame: index, - at: current.at, - row: before.id, - movement, - idle, - excessForward, - resizedRows: current.rows.flatMap((row) => { - const old = previous.rows.find((candidate) => candidate.id === row.id); - return old && old.height !== row.height - ? [{ id: row.id, before: old.height, after: row.height }] - : []; - }), - loading: current.loading || previous.loading, - virtualized: current.virtualized, - heightChange: current.scrollHeight - previous.scrollHeight, - imageLoads: current.imageLoads - previous.imageLoads, - frameGapMs: current.at - previous.at, - }, - ]; - }); -} - export async function reportScrollJumps( page: Page, testInfo: TestInfo, diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index e2d67aca70..7b9d4407b3 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -18,7 +18,7 @@ const resolvePackageEntry = (packageName: string) => { export default defineConfig({ test: { environment: "node", - exclude: [...configDefaults.exclude, "e2e/**"], + exclude: configDefaults.exclude, projects: [ { extends: true, @@ -27,12 +27,13 @@ export default defineConfig({ environment: "node", include: [ "src/**/*.{test,spec}.{ts,tsx}", + "e2e/support/helpers/*.test.ts", "native-release-version.test.ts", "app.config.test.ts", "vitest-environment.test.ts", ], setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], - exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"], + exclude: [...configDefaults.exclude, "src/**/*.browser.{test,spec}.{ts,tsx}"], }, }, {