test(app): trim repeated browser journeys and scripted waits

This commit is contained in:
Mohamed Boudra
2026-10-17 18:37:38 +02:00
parent a4eb0adcf6
commit e109b866ab
25 changed files with 1206 additions and 1513 deletions
+2
View File
@@ -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
@@ -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,
+438 -442
View File
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>("[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<HTMLElement>("[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<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
@@ -2218,3 +1975,242 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
});
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
}
async function readHorizontalDiffOffset(page: Page): Promise<number> {
return page
.getByTestId("diff-file-0-horizontal-scroll")
.evaluate((element) => element.scrollLeft);
}
async function inspectChangesWorkspaceControls(
page: Page,
workspace: DirtyWorkspace,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
+75 -23
View File
@@ -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 &amp; 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<void>;
advancePastVisiblePrefix(): Promise<void>;
expectHydratedPartial(): Promise<void>;
replyFinished(): Promise<void>;
}
/**
* 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<StreamedReplyChat> {
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<void> {
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");
@@ -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);
@@ -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();
}
@@ -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<void> {
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);
}
});
});
@@ -107,73 +107,122 @@ async function rejectNextLabelAssignment(page: Page): Promise<void> {
});
}
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<void> {
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-" });
@@ -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();
}
});
});
@@ -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-" });
@@ -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
@@ -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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
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();
}
});
});
@@ -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");
});
});
});
@@ -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);
});
});
});
@@ -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<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
}
@@ -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 ({
+26 -54
View File
@@ -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)");
@@ -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<void> {
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.
@@ -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");
@@ -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<AcpStreamMessage>;
return {
readable: new ReadableStream<AcpStreamMessage>({
start(value) {
controller = value;
},
}),
writable: new WritableStream<AcpStreamMessage>({
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 });
},
}),
};
}
}
@@ -0,0 +1 @@
{ "id": "controlled-stream-test", "requirements": { "paseo": ">=0.8.0" } }
@@ -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);
}
});
@@ -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,
},
];
});
}
@@ -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,
+3 -2
View File
@@ -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}"],
},
},
{