import { expect, type Page, test } from "@playwright/test"; import { startReviewServer } from "../server.js"; import type { AskRequest, Draft, Feedback, Review, ReviewServer, } from "../types.js"; const longLine = `const horizontal = "${"x".repeat(360)}";`; function makeReview(id = "keyboard-snapshot"): Review { return { snapshot: { id, repoRoot: "/safe/keyboard-project", source: { kind: "working" }, base: "keyboard-base", head: "keyboard-head", hunks: [ { id: "alpha-long", path: "src/alpha-long.ts", header: "@@ -10,112 +10,112 @@ alpha", lines: [ { kind: "delete", text: "const mode = 'old';", oldLine: 10 }, { kind: "add", text: "const mode = 'new';", newLine: 10 }, { kind: "context", text: "const firstNeedle = 'Needle';", oldLine: 11, newLine: 11, }, { kind: "context", text: longLine, oldLine: 12, newLine: 12, }, ...Array.from({ length: 108 }, (_, index) => ({ kind: "context" as const, text: `alpha filler ${String(index).padStart(3, "0")}`, oldLine: index + 13, newLine: index + 13, })), ], }, { id: "alpha-literal", path: "src/alpha-literal.ts", header: "@@ -200,2 +200,3 @@ literal", lines: [ { kind: "context", text: "export function literal() {", oldLine: 200, newLine: 200, }, { kind: "add", text: ' return "[literal.*]";', newLine: 201, }, { kind: "context", text: "}", oldLine: 201, newLine: 202, }, ], }, { id: "beta-change", path: "src/beta-change.ts", header: "@@ -300,2 +300,2 @@ beta", lines: [ { kind: "delete", text: "const beta = false;", oldLine: 300 }, { kind: "add", text: "const beta = true;", newLine: 300 }, { kind: "context", text: 'const secondNeedle = "needle";', oldLine: 301, newLine: 301, }, ], }, { id: "beta-final", path: "src/beta-final.ts", header: "@@ -400 +400,2 @@ final", lines: [ { kind: "context", text: "export const final = true;", oldLine: 400, newLine: 400, }, { kind: "add", text: 'export const thirdNeedle = "NEEDLE";', newLine: 401, }, ], }, ], skipped: Array.from({ length: 24 }, (_, index) => ({ path: `generated/omitted-${String(index).padStart(2, "0")}.bin`, reason: "binary", })), }, plan: { summary: "Review every keyboard fixture change.", cohorts: [ { title: "Alpha", layers: [ { id: "alpha", title: "Alpha layer", summary: "A long hunk followed by a literal-search hunk.", hunks: [ { id: "alpha-long", summary: Array.from( { length: 35 }, (_, index) => `Long explanation line ${index + 1}.`, ).join("\n"), }, { id: "alpha-literal", summary: "Literal punctuation search.", }, ], flow: Array.from( { length: 18 }, (_, index) => `Alpha step ${index + 1}`, ), }, { id: "beta", title: "Beta layer", summary: "Two compact hunks with separate search matches.", hunks: [ { id: "beta-change", summary: "Changes the beta switch.", }, { id: "beta-final", summary: "Adds the final mixed-case search match.", }, ], }, ], }, ], }, draft: { reviewed: ["alpha-literal", "beta-final"], findings: [], notes: "", }, }; } function makeEmptyReview(): Review { const review = makeReview("keyboard-empty"); review.snapshot.hunks = []; review.snapshot.skipped = []; review.plan.cohorts = []; review.draft = { reviewed: [], findings: [], notes: "" }; return review; } function makeMetadataReview(): Review { const review = makeReview("keyboard-metadata"); review.snapshot.hunks = [ { id: "metadata-first", path: "assets/first-mode.sh", header: "old mode 100644\nnew mode 100755", lines: [{ kind: "meta", text: "old mode 100644" }], }, { id: "code-before", path: "src/before.ts", header: "@@ -1 +1 @@", lines: [{ kind: "add", text: "export const before = 1;", newLine: 1 }], }, { id: "metadata-middle", path: "assets/middle-mode.sh", header: "old mode 100644\nnew mode 100755", lines: [{ kind: "meta", text: "new mode 100755" }], }, { id: "code-after", path: "src/after.ts", header: "@@ -1 +1 @@", lines: [{ kind: "add", text: "export const after = 1;", newLine: 1 }], }, ]; review.plan.cohorts = [ { title: "Metadata", layers: [ { id: "metadata", title: "Metadata and code", summary: "Review metadata-only and text hunks together.", hunks: review.snapshot.hunks.map((hunk) => ({ id: hunk.id, summary: `Review ${hunk.path}.`, })), }, ], }, ]; review.draft = { reviewed: ["code-before"], findings: [], notes: "" }; return review; } function makeViewportReview(): Review { const review = makeReview("keyboard-viewport"); const hunk = (id: string, lineCount: number) => ({ id, path: `src/${id}.ts`, header: `@@ -1,${lineCount} +1,${lineCount} @@`, lines: Array.from({ length: lineCount }, (_, index) => ({ kind: "context" as const, text: `${id} line ${index + 1}`, oldLine: index + 1, newLine: index + 1, })), }); review.snapshot.hunks = [ hunk("compact-first", 1), hunk("huge-middle", 140), hunk("compact-last", 1), ]; review.plan.cohorts = [ { title: "Viewport", layers: [ { id: "viewport", title: "Viewport geometry", summary: "Compact hunks surround one huge hunk.", hunks: review.snapshot.hunks.map((item) => ({ id: item.id, summary: item.path, })), }, ], }, ]; review.draft = { reviewed: [], findings: [], notes: "" }; return review; } function currentLine(page: Page) { return page.locator(".diff-line.keyboard-current"); } async function expectCurrentLine( page: Page, hunkId: string, side: "old" | "new", line: number, ) { const current = currentLine(page); await expect(current).toHaveCount(1); await expect(current).toHaveAttribute("data-hunk-id", hunkId); await expect(current).toHaveAttribute("data-side", side); await expect(current).toHaveAttribute("data-line", String(line)); } async function pressSequence(page: Page, prefix: string, continuation: string) { await page.keyboard.press(prefix); await page.keyboard.press(continuation); } async function closeKeyboardHelp(page: Page) { await page.locator("#keyboard-help").evaluate((dialog) => { dialog.removeAttribute("data-close-observed"); dialog.addEventListener( "close", () => dialog.setAttribute("data-close-observed", "true"), { once: true }, ); }); await page.keyboard.press("Escape"); await expect(page.locator("#keyboard-help")).toHaveAttribute( "data-close-observed", "true", ); } async function expectNoViewportOverflow(page: Page) { expect( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), ).toBe(true); } let server: ReviewServer | undefined; test.afterEach(() => { server?.close(); server = undefined; }); test("exposes contextual keyboard UI and complete cancellable prefix help", async ({ page, }) => { server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.goto(server.url); await expect(page.locator("#status")).toHaveText("Review ready"); await expect(page.locator("#keyboard-status")).toContainText("NORMAL"); await expect(page.locator("#keyboard-status")).toContainText("line 10"); await expect(page.locator("#keyboard-help-button")).toBeVisible(); await expect(page.locator("#keyboard-help")).toHaveJSProperty("open", false); await expect(page.locator("#keyboard-guide")).toBeHidden(); await expect(page.locator('[data-keyboard-pane="layers"]')).toHaveCount(1); await expect(page.locator('[data-keyboard-pane="diff"]')).toHaveCount(1); await expect(page.locator('[data-keyboard-pane="drawer"]')).toHaveCount(1); await expect( page.locator('[data-keyboard-pane="diff"].keyboard-focused'), ).toBeVisible(); await expectCurrentLine(page, "alpha-long", "old", 10); await expect( page.getByRole("button", { name: "Mark reviewed", exact: true }).first(), ).toBeVisible(); await expect( page .getByRole("button", { name: "Mark reviewed", exact: true }) .first() .locator("kbd"), ).toHaveText(["Space", "r"]); await expect(page.locator("#mark-layer-reviewed")).toBeDisabled(); await expect(page.locator(".layer-nav kbd")).toHaveCount(0); for (const [prefix, continuations] of [ [ "Space", [ "Review", "Writ", "Workspace", "r", "R", "c", "a", "n", "b", "d", "s", "f", "q", ], ], ["g", ["d", "a", "f"]], ["[", ["c", "l", "u"]], ["]", ["c", "l", "u"]], ] as const) { await page.keyboard.press(prefix); await expect(page.locator("#keyboard-guide")).toBeVisible(); for (const continuation of continuations) { await expect(page.locator("#keyboard-guide")).toContainText(continuation); } await page.keyboard.press("Escape"); await expect(page.locator("#keyboard-guide")).toBeHidden(); } await page.keyboard.press("g"); await page.keyboard.press("x"); await expect(page.locator("#keyboard-guide")).toBeHidden(); await expect(page.locator("#keyboard-status")).toContainText("NORMAL"); await page.keyboard.press("?"); const help = page.locator("#keyboard-help"); await expect(help).toHaveAttribute("open", ""); const helpKeys = await help.locator("kbd").allTextContents(); for (const key of [ "j", "k", "h", "l", "Ctrl+d", "Ctrl+u", "gg", "G", "]c", "[c", "]l", "[l", "]u", "[u", "/", "n", "N", "Space r", "Space R", "Space c", "Space a", "Space b", "gd", "ga", "gf", "Space d", "Space s", "Space f", "Space q", ]) { expect(helpKeys).toContain(key); } await expect(help).toContainText(/toggle.*hunk/i); await expect(help).toContainText(/toggle.*layer/i); await expect(help).toContainText(/comment/i); await expect(help).toContainText(/notes/i); await expect(help).toContainText(/refresh/i); await expect(help).toContainText(/close/i); for (let index = 0; index < 20; index++) await page.keyboard.press("Tab"); expect( await page.evaluate( () => document.activeElement?.closest("#keyboard-help")?.id, ), ).toBe("keyboard-help"); await page.keyboard.press("Escape"); await expect(help).not.toHaveAttribute("open", ""); await page.locator("#keyboard-help-button").press("Enter"); await expect(help).toHaveAttribute("open", ""); await page.keyboard.press("Escape"); await page.keyboard.press("["); await expect(page.locator("#keyboard-guide")).toContainText("previous hunk"); await expect(page.locator("#keyboard-guide")).toContainText("previous layer"); await expect(page.locator("#keyboard-guide")).not.toContainText("next hunk"); await page.keyboard.press("Escape"); }); test("navigates lines, hunks, layers, unreviewed work, and large hunks within bounds", async ({ page, }) => { server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.goto(server.url); await expectCurrentLine(page, "alpha-long", "old", 10); await page.keyboard.press("k"); await expectCurrentLine(page, "alpha-long", "old", 10); await page.keyboard.press("j"); await expectCurrentLine(page, "alpha-long", "new", 10); await page.keyboard.press("k"); await expectCurrentLine(page, "alpha-long", "old", 10); await pressSequence(page, "g", "g"); await expectCurrentLine(page, "alpha-long", "old", 10); await page.keyboard.press("G"); await expectCurrentLine(page, "beta-final", "new", 401); await page.keyboard.press("j"); await expectCurrentLine(page, "beta-final", "new", 401); await pressSequence(page, "]", "l"); await expect(page.locator("#diff-heading")).toHaveText("Alpha layer"); await expectCurrentLine(page, "alpha-long", "old", 10); await pressSequence(page, "[", "l"); await expect(page.locator("#diff-heading")).toHaveText("Alpha layer"); await pressSequence(page, "]", "c"); await expectCurrentLine(page, "alpha-literal", "new", 200); await expect(page.locator("#current-path")).toHaveText( "src/alpha-literal.ts", ); await expect(page.locator("#ask-focus")).toContainText( "src/alpha-literal.ts", ); await page.waitForTimeout(650); await expectCurrentLine(page, "alpha-literal", "new", 200); await pressSequence(page, "]", "c"); await expectCurrentLine(page, "alpha-literal", "new", 200); await pressSequence(page, "[", "c"); await expectCurrentLine(page, "alpha-long", "old", 10); await page.waitForTimeout(650); await expectCurrentLine(page, "alpha-long", "old", 10); await pressSequence(page, "[", "c"); await expectCurrentLine(page, "alpha-long", "old", 10); await pressSequence(page, "]", "l"); await expect(page.locator("#diff-heading")).toHaveText("Beta layer"); await pressSequence(page, "]", "l"); await expect(page.locator("#diff-heading")).toHaveText("Beta layer"); await pressSequence(page, "[", "l"); await expect(page.locator("#diff-heading")).toHaveText("Alpha layer"); await page.locator("#all-changes").click(); await expectCurrentLine(page, "alpha-long", "old", 10); await pressSequence(page, "]", "u"); await expectCurrentLine(page, "beta-change", "old", 300); await pressSequence(page, "[", "u"); await expectCurrentLine(page, "alpha-long", "old", 10); await pressSequence(page, "[", "u"); await expectCurrentLine(page, "alpha-long", "old", 10); }); test("navigates and reviews first and middle metadata-only hunks", async ({ page, }) => { const saved: Draft[] = []; server = await startReviewServer({ review: makeMetadataReview(), isCurrent: async () => true, refresh: async () => makeMetadataReview(), ask: async () => "answer", save: (draft) => saved.push(structuredClone(draft)), submit: async () => {}, }); await page.goto(server.url); await expect(page.locator("#current-path")).toHaveText( "assets/first-mode.sh", ); await expect(currentLine(page)).toHaveCount(0); await pressSequence(page, "Space", "c"); await expect(page.locator("#comment-dialog")).not.toHaveAttribute("open", ""); await pressSequence(page, "Space", "r"); await expect.poll(() => saved.length).toBe(1); expect(saved[0].reviewed).toEqual(["code-before", "metadata-first"]); await pressSequence(page, "]", "c"); await expectCurrentLine(page, "code-before", "new", 1); await pressSequence(page, "]", "c"); await expect(page.locator("#current-path")).toHaveText( "assets/middle-mode.sh", ); await expect(currentLine(page)).toHaveCount(0); await pressSequence(page, "[", "c"); await expectCurrentLine(page, "code-before", "new", 1); await pressSequence(page, "[", "c"); await expect(page.locator("#current-path")).toHaveText( "assets/first-mode.sh", ); await expect(currentLine(page)).toHaveCount(0); await pressSequence(page, "]", "u"); await expect(page.locator("#current-path")).toHaveText( "assets/middle-mode.sh", ); await expect(currentLine(page)).toHaveCount(0); await pressSequence(page, "Space", "r"); await expect.poll(() => saved.length).toBe(2); expect(saved[1].reviewed).toEqual([ "code-before", "metadata-first", "metadata-middle", ]); }); test("updates scroll observations after explicit-jump suppression by visible area", async ({ page, }) => { server = await startReviewServer({ review: makeViewportReview(), isCurrent: async () => true, refresh: async () => makeViewportReview(), ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.setViewportSize({ width: 1100, height: 520 }); await page.goto(server.url); await page.locator("#hunk-huge-middle").click(); await pressSequence(page, "]", "c"); await expectCurrentLine(page, "compact-last", "new", 1); await page.waitForTimeout(600); await page.locator(".diff-region").hover(); await page.mouse.wheel(0, -180); await expect(page.locator("#current-path")).toHaveText("src/huge-middle.ts"); await expect(currentLine(page)).toHaveAttribute( "data-hunk-id", "huge-middle", ); await page.locator(".diff-region").evaluate((element) => { element.scrollTop = 0; }); await expect(page.locator("#current-path")).toHaveText("src/huge-middle.ts"); }); test("scrolls and focuses only visible panes", async ({ page }) => { server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.setViewportSize({ width: 1100, height: 520 }); await page.goto(server.url); const horizontalBefore = await page .locator('.hunk[data-hunk-id="alpha-long"] .diff-scroll') .evaluate((element) => element.scrollLeft); await page.keyboard.press("l"); await expect .poll(() => page .locator('.hunk[data-hunk-id="alpha-long"] .diff-scroll') .evaluate((element) => element.scrollLeft), ) .toBeGreaterThan(horizontalBefore); await page.keyboard.press("h"); await expect .poll(() => page .locator('.hunk[data-hunk-id="alpha-long"] .diff-scroll') .evaluate((element) => element.scrollLeft), ) .toBeLessThan(horizontalBefore + 1); await pressSequence(page, "g", "g"); const diffPane = page.locator('[data-keyboard-pane="diff"]'); await page.keyboard.press("Control+d"); await expect .poll(() => diffPane.evaluate((element) => element.scrollTop)) .toBeGreaterThan(0); const halfPage = await diffPane.evaluate((element) => element.scrollTop); await page.keyboard.press("Control+u"); await expect .poll(() => diffPane.evaluate((element) => element.scrollTop)) .toBeLessThan(halfPage); await pressSequence(page, "Space", "h"); const layersPane = page.locator( '[data-keyboard-pane="layers"].keyboard-focused', ); await expect(layersPane).toBeVisible(); await page.keyboard.press("G"); await expect .poll(() => layersPane.evaluate((element) => element.scrollTop)) .toBeGreaterThan(0); await pressSequence(page, "g", "g"); await expect .poll(() => layersPane.evaluate((element) => element.scrollTop)) .toBe(0); await page.keyboard.press("j"); await expect .poll(() => layersPane.evaluate((element) => element.scrollTop)) .toBeGreaterThan(0); const layerScroll = await layersPane.evaluate((element) => element.scrollTop); await page.keyboard.press("k"); await expect .poll(() => layersPane.evaluate((element) => element.scrollTop)) .toBeLessThan(layerScroll); await pressSequence(page, "Space", "l"); await expect( page.locator('[data-keyboard-pane="diff"].keyboard-focused'), ).toBeVisible(); await pressSequence(page, "Space", "l"); const drawerPane = page.locator( '[data-keyboard-pane="drawer"].keyboard-focused', ); const drawerScroll = page.locator("#details-panel"); await expect(drawerPane).toBeVisible(); await page.keyboard.press("G"); await expect .poll(() => drawerScroll.evaluate((element) => element.scrollTop)) .toBeGreaterThan(0); await pressSequence(page, "g", "g"); await expect .poll(() => drawerScroll.evaluate((element) => element.scrollTop)) .toBe(0); await pressSequence(page, "g", "a"); await expect(page.getByRole("tab", { name: "Ask" })).toHaveAttribute( "aria-selected", "true", ); await pressSequence(page, "g", "f"); await expect(page.getByRole("tab", { name: "Feedback" })).toHaveAttribute( "aria-selected", "true", ); await pressSequence(page, "g", "d"); await expect(page.getByRole("tab", { name: "Details" })).toHaveAttribute( "aria-selected", "true", ); await drawerPane.focus(); await pressSequence(page, "Space", "d"); await expect(page.locator('[data-keyboard-pane="drawer"]')).toBeHidden(); await expect( page.locator('[data-keyboard-pane="drawer"].keyboard-focused'), ).toHaveCount(0); await expect( page.locator("[data-keyboard-pane].keyboard-focused:visible"), ).toHaveCount(1); await pressSequence(page, "Space", "l"); await expect( page.locator('[data-keyboard-pane="diff"].keyboard-focused'), ).toBeVisible(); await pressSequence(page, "Space", "h"); await expect( page.locator('[data-keyboard-pane="layers"].keyboard-focused'), ).toBeVisible(); await pressSequence(page, "Space", "b"); await expect(page.locator('[data-keyboard-pane="layers"]')).toBeHidden(); await expect( page.locator('[data-keyboard-pane="diff"].keyboard-focused'), ).toBeVisible(); }); test("searches visible diff text literally and repeats with wrapping", async ({ page, }) => { server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.goto(server.url); await page.keyboard.press("/"); await expect(page.locator("#keyboard-search")).toBeVisible(); await expect(page.locator("#keyboard-search")).toBeFocused(); await expect(page.locator("#keyboard-status")).toContainText("TEXT"); await page.locator("#keyboard-search").fill("[literal.*]"); await page.locator("#keyboard-search").press("Enter"); await expectCurrentLine(page, "alpha-literal", "new", 201); await page.keyboard.press("n"); await expectCurrentLine(page, "alpha-literal", "new", 201); await page.keyboard.press("/"); await page.locator("#keyboard-search").fill("needle"); await page.locator("#keyboard-search").press("Enter"); const first = await currentLine(page).getAttribute("data-hunk-id"); await page.keyboard.press("n"); const second = await currentLine(page).getAttribute("data-hunk-id"); await page.keyboard.press("n"); const third = await currentLine(page).getAttribute("data-hunk-id"); expect(new Set([first, second, third])).toEqual( new Set(["alpha-long", "beta-change", "beta-final"]), ); await page.keyboard.press("n"); await expect(currentLine(page)).toHaveAttribute("data-hunk-id", first ?? ""); await page.keyboard.press("N"); await expect(currentLine(page)).toHaveAttribute("data-hunk-id", third ?? ""); await page.locator('[data-layer-id="alpha"]').click(); const beforeMissingSearch = await currentLine(page).getAttribute("data-line"); await page.keyboard.press("/"); await page.locator("#keyboard-search").fill("secondNeedle"); await page.locator("#keyboard-search").press("Enter"); await expect(page.locator("#status")).toContainText("No matches"); await expect(currentLine(page)).toHaveAttribute( "data-line", beforeMissingSearch ?? "", ); await page.keyboard.press("/"); await page.locator("#keyboard-search").fill("unfinished"); await page.locator("#keyboard-search").press("Escape"); await expect(page.locator("#keyboard-search")).toBeHidden(); await expect(page.locator("#keyboard-status")).toContainText("NORMAL"); }); test("routes line, hunk, and whole-layer actions through persisted scope-safe drafts", async ({ page, }) => { const saved: Draft[] = []; let rejectSave = false; server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => "answer", save: (draft) => { if (rejectSave) throw new Error("persist blocked"); saved.push(structuredClone(draft)); }, submit: async () => {}, }); await page.goto(server.url); await expect(page.locator("#status")).toHaveText("Review ready"); await pressSequence(page, "]", "l"); await expect(page.locator("#mark-layer-reviewed")).toBeEnabled(); await expect( page.getByRole("button", { name: "Mark layer reviewed", exact: true }), ).toHaveCount(1); await expect(page.locator("#mark-layer-reviewed kbd")).toHaveText([ "Space", "R", ]); await pressSequence(page, "Space", "n"); await expect(page.locator("#notes")).toBeFocused(); await page .locator("#notes") .fill("Keep this note across every review action."); await expect(page.locator("#status")).toHaveText("Draft saved"); await page.keyboard.press("Escape"); await pressSequence(page, "Space", "a"); await expect(page.locator("#question")).toBeFocused(); await page.locator("#question").fill("Alpha unsent question"); await page.keyboard.press("Escape"); await pressSequence(page, "]", "l"); await pressSequence(page, "Space", "a"); await page.locator("#question").fill("Beta unsent question"); await page.keyboard.press("Escape"); await pressSequence(page, "[", "l"); await pressSequence(page, "Space", "a"); await expect(page.locator("#question")).toHaveValue("Alpha unsent question"); await page.keyboard.press("Escape"); await pressSequence(page, "g", "g"); await expectCurrentLine(page, "alpha-long", "old", 10); await pressSequence(page, "Space", "c"); await expect(page.locator("#comment-dialog")).toHaveAttribute("open", ""); await expect(page.locator("#comment-anchor")).toContainText("old:10"); await page.locator("#comment-text").fill("Old-side keyboard finding."); await page.getByRole("button", { name: "Add comment", exact: true }).click(); await expect(page.locator(".finding")).toContainText( "Old-side keyboard finding.", ); await currentLine(page).focus(); await page.keyboard.press("j"); await expectCurrentLine(page, "alpha-long", "new", 10); await pressSequence(page, "Space", "c"); await expect(page.locator("#comment-anchor")).toContainText("new:10"); await page.getByRole("button", { name: "Cancel", exact: true }).click(); const beforeHunkToggle = saved.length; await pressSequence(page, "Space", "r"); await expect.poll(() => saved.length).toBe(beforeHunkToggle + 1); expect(saved.at(-1)?.reviewed).toEqual([ "alpha-literal", "beta-final", "alpha-long", ]); await pressSequence(page, "Space", "r"); await expect.poll(() => saved.length).toBe(beforeHunkToggle + 2); expect(saved.at(-1)?.reviewed).toEqual(["alpha-literal", "beta-final"]); const beforeWholeLayer = saved.length; await pressSequence(page, "Space", "R"); await expect.poll(() => saved.length).toBe(beforeWholeLayer + 1); expect(new Set(saved.at(-1)?.reviewed)).toEqual( new Set(["alpha-long", "alpha-literal", "beta-final"]), ); await expect( page.getByRole("button", { name: "Unmark layer reviewed", exact: true }), ).toHaveCount(1); await pressSequence(page, "Space", "R"); await expect.poll(() => saved.length).toBe(beforeWholeLayer + 2); expect(saved.at(-1)?.reviewed).toEqual(["beta-final"]); rejectSave = true; await pressSequence(page, "Space", "r"); await expect(page.locator("#status")).toHaveText("persist blocked"); await expect( page.getByRole("button", { name: "Mark reviewed", exact: true }).first(), ).toBeVisible(); await pressSequence(page, "g", "f"); await expect(page.locator("#notes")).toHaveValue( "Keep this note across every review action.", ); await expect(page.locator(".finding")).toContainText( "Old-side keyboard finding.", ); await pressSequence(page, "Space", "a"); await expect(page.locator("#question")).toHaveValue("Alpha unsent question"); await page.keyboard.press("Escape"); await page.locator("#all-changes").click(); await expect(page.locator("#mark-layer-reviewed")).toBeDisabled(); const savesAtAllChanges = saved.length; await pressSequence(page, "Space", "R"); await page.waitForTimeout(100); expect(saved).toHaveLength(savesAtAllChanges); }); test("preserves the latest optimistic draft across delayed saves and flushes", async ({ page, }) => { const saved: Draft[] = []; const submitted: Feedback[] = []; let refreshes = 0; let requestCount = 0; const releases = new Map void>(); const gates = new Map>(); for (const index of [1, 2, 4, 5]) { gates.set( index, new Promise((resolve) => releases.set(index, resolve)), ); } server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => { refreshes++; return makeReview("keyboard-refreshed"); }, ask: async () => "answer", save: (draft) => saved.push(structuredClone(draft)), submit: async (feedback) => { submitted.push(structuredClone(feedback)); }, }); await page.route("**/api/draft", async (route) => { const index = ++requestCount; const response = await route.fetch(); await gates.get(index); await route.fulfill({ response }); }); await page.goto(server.url); await page.locator('.mark-reviewed[data-hunk-id="alpha-long"]').click(); await expect.poll(() => requestCount).toBe(1); await page.locator('.mark-reviewed[data-hunk-id="alpha-literal"]').click(); releases.get(1)?.(); await expect.poll(() => requestCount).toBe(2); await page.locator('.mark-reviewed[data-hunk-id="beta-change"]').click(); releases.get(2)?.(); await page.getByRole("tab", { name: "Feedback" }).click(); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Send feedback to Pi" }).click(); await expect(page.locator("#status")).toHaveText("Feedback sent to Pi"); expect(new Set(submitted[0].reviewed)).toEqual( new Set(["beta-final", "alpha-long", "beta-change"]), ); await page.locator('.mark-reviewed[data-hunk-id="alpha-long"]').click(); await expect.poll(() => requestCount).toBe(4); await page.locator('.mark-reviewed[data-hunk-id="alpha-literal"]').click(); releases.get(4)?.(); await expect.poll(() => requestCount).toBe(5); await page.locator('.mark-reviewed[data-hunk-id="beta-change"]').click(); releases.get(5)?.(); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.locator("#source-label")).toContainText("keyboard-ref"); expect(refreshes).toBe(1); expect(new Set(saved.at(-1)?.reviewed)).toEqual( new Set(["alpha-long", "alpha-literal", "beta-change"]), ); }); test("guards native controls, text entry, composition, and unrelated modifiers", async ({ page, }) => { const requests: AskRequest[] = []; const saved: Draft[] = []; server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async (request) => { requests.push(structuredClone(request)); return "answer"; }, save: (draft) => saved.push(structuredClone(draft)), submit: async () => {}, }); await page.goto(server.url); await expectCurrentLine(page, "alpha-long", "old", 10); for (const event of [ { key: "j", altKey: true }, { key: "j", ctrlKey: true }, { key: "J", shiftKey: true }, { key: "j", metaKey: true }, ]) { await currentLine(page).evaluate((element, init) => { element.dispatchEvent( new KeyboardEvent("keydown", { ...init, bubbles: true }), ); }, event); await expectCurrentLine(page, "alpha-long", "old", 10); } await currentLine(page).evaluate((element) => { element.dispatchEvent( new KeyboardEvent("keydown", { key: "?", code: "Slash", shiftKey: true, bubbles: true, }), ); }); await expect(page.locator("#keyboard-help")).toHaveAttribute("open", ""); await page.keyboard.press("Escape"); for (const event of [ { key: "/", shiftKey: true }, { key: "/", altKey: true, ctrlKey: true, modifierAltGraph: true, }, ]) { await currentLine(page).evaluate((element, init) => { element.dispatchEvent( new KeyboardEvent("keydown", { ...init, bubbles: true }), ); }, event); await expect(page.locator("#keyboard-search")).toBeVisible(); await page.keyboard.press("Escape"); } await currentLine(page).evaluate((element) => { element.dispatchEvent( new KeyboardEvent("keydown", { key: "[", altKey: true, ctrlKey: true, modifierAltGraph: true, bubbles: true, }), ); }); await expect(page.locator("#keyboard-guide")).toContainText("previous hunk"); await page.keyboard.press("Escape"); await page.locator(".diff-region").focus(); await page.keyboard.press("Space"); await expect(page.locator("#keyboard-guide")).toBeVisible(); await page.keyboard.press("Tab"); await expect(page.locator("#keyboard-guide")).toBeHidden(); await expect(page.locator(".diff-region")).not.toBeFocused(); await page.locator(".diff-region").focus(); await page.locator(".diff-region").evaluate((element) => { element.scrollTop = 0; }); await page.keyboard.press("g"); await page.keyboard.press("Control+d"); await expect(page.locator("#keyboard-guide")).toBeHidden(); await expect .poll(() => page.locator(".diff-region").evaluate((element) => element.scrollTop), ) .toBeGreaterThan(0); await page.keyboard.press("g"); await page.keyboard.press("Alt+j"); await expect(page.locator("#keyboard-guide")).toBeHidden(); await pressSequence(page, "Space", "a"); const question = page.locator("#question"); await expect(question).toBeFocused(); await question.pressSequentially("jkG? / n Space r"); await question.press("Enter"); await question.press("Space"); await expect(question).toHaveValue("jkG? / n Space r\n "); await expect(page.locator("#keyboard-guide")).toBeHidden(); await expect(page.locator("#keyboard-help")).not.toHaveAttribute("open", ""); await expectCurrentLine(page, "alpha-long", "old", 10); await question.evaluate((element) => { element.dispatchEvent( new KeyboardEvent("keydown", { key: "j", bubbles: true, isComposing: true, }), ); element.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true, isComposing: true, }), ); element.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, isComposing: true, }), ); }); await expect(question).toBeFocused(); expect(requests).toHaveLength(0); await expectCurrentLine(page, "alpha-long", "old", 10); await question.press("Escape"); await expect(page.locator("#keyboard-status")).toContainText("NORMAL"); await expect(currentLine(page)).toBeFocused(); await page.evaluate(() => { const editable = document.createElement("div"); editable.id = "keyboard-editable-fixture"; editable.contentEditable = "true"; editable.tabIndex = 0; document.body.append(editable); editable.focus(); }); await page.keyboard.type("jG?"); await expect(page.locator("#keyboard-editable-fixture")).toHaveText("jG?"); await expectCurrentLine(page, "alpha-long", "old", 10); await page .locator("#flow .flow-step") .first() .evaluate((element) => { element.dataset.retainedWithinHunk = "true"; }); await currentLine(page).focus(); await page.keyboard.press("j"); await expectCurrentLine(page, "alpha-long", "new", 10); await expect( page.locator('#flow .flow-step[data-retained-within-hunk="true"]'), ).toHaveCount(1); const mark = page.locator('.mark-reviewed[data-hunk-id="alpha-long"]'); await mark.focus(); const beforeNativeSpace = saved.length; await mark.press("Space"); await expect.poll(() => saved.length).toBe(beforeNativeSpace + 1); await expect(page.locator("#keyboard-guide")).toBeHidden(); await expect(mark).toHaveAttribute("aria-pressed", "true"); await expect(mark).toBeFocused(); await page.keyboard.press("Tab"); await expect(mark).not.toBeFocused(); await expect(mark).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#keyboard-guide")).toBeHidden(); await page.locator("#keyboard-help-button").focus(); await page.keyboard.press("Tab"); await expect(page.locator("#keyboard-guide")).toBeHidden(); await page.locator("#keyboard-help-button").press("Enter"); await expect(page.locator("#keyboard-help")).toHaveAttribute("open", ""); await page.keyboard.press("Escape"); await currentLine(page).focus(); await pressSequence(page, "Space", "a"); await question.fill("Shift Enter still submits"); await question.press("Shift+Enter"); await expect.poll(() => requests.length).toBe(1); expect(requests[0].question).toBe("Shift Enter still submits"); }); test("uses shared confirmations for mouse and keyboard submit, refresh, and close", async ({ page, }) => { const submitted: Feedback[] = []; let refreshes = 0; let closes = 0; server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => { refreshes++; return makeReview("keyboard-refreshed"); }, ask: async () => "answer", save: () => {}, submit: async (feedback) => { submitted.push(structuredClone(feedback)); }, onClose: () => closes++, }); await page.goto(server.url); await pressSequence(page, "Space", "n"); await page.locator("#notes").fill("Confirm before sending this note."); await expect(page.locator("#status")).toHaveText("Draft saved"); await page.keyboard.press("Escape"); let submitPrompt = ""; page.once("dialog", async (dialog) => { submitPrompt = dialog.message(); await dialog.dismiss(); }); await page.getByRole("button", { name: "Send feedback to Pi" }).click(); expect(submitPrompt).toMatch(/send feedback/i); expect(submitted).toHaveLength(0); await expect(page.locator("#notes")).toHaveValue( "Confirm before sending this note.", ); page.once("dialog", (dialog) => dialog.accept()); await pressSequence(page, "Space", "s"); await expect(page.locator("#status")).toHaveText("Feedback sent to Pi"); expect(submitted).toHaveLength(1); await expect(page.locator("#notes")).toHaveValue(""); let refreshPrompt = ""; page.once("dialog", async (dialog) => { refreshPrompt = dialog.message(); await dialog.dismiss(); }); await pressSequence(page, "Space", "f"); expect(refreshPrompt).toMatch(/refresh/i); expect(refreshes).toBe(0); await expectCurrentLine(page, "alpha-long", "old", 10); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.locator("#source-label")).toContainText("keyboard-ref"); expect(refreshes).toBe(1); await expectCurrentLine(page, "alpha-long", "old", 10); await expect(page.locator("#keyboard-status")).toContainText("line 10"); let closePrompt = ""; page.once("dialog", async (dialog) => { closePrompt = dialog.message(); await dialog.dismiss(); }); await page.getByRole("button", { name: "Close", exact: true }).click(); expect(closePrompt).toMatch(/close/i); expect(closes).toBe(0); await page.keyboard.press("j"); await expectCurrentLine(page, "alpha-long", "new", 10); page.once("dialog", (dialog) => dialog.accept()); await pressSequence(page, "Space", "q"); await expect(page.locator("#status")).toContainText("Review closed"); await expect.poll(() => closes).toBe(1); }); test("blocks stale keyboard actions while busy and handles an empty review", async ({ page, }) => { let answer!: (value: string) => void; let askStarted!: () => void; const didStart = new Promise((resolve) => { askStarted = resolve; }); const saved: Draft[] = []; server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => new Promise((resolve) => { answer = resolve; askStarted(); }), save: (draft) => saved.push(structuredClone(draft)), submit: async () => {}, }); await page.goto(server.url); await pressSequence(page, "Space", "a"); await page.locator("#question").fill("Hold the server busy"); await page.locator("#question").press("Shift+Enter"); await didStart; const waitingPage = await page.context().newPage(); await waitingPage.goto(server.url); await expect(waitingPage.locator("#workspace")).toHaveAttribute( "aria-busy", "true", ); await expectCurrentLine(waitingPage, "alpha-long", "old", 10); const savesWhileBusy = saved.length; await waitingPage.keyboard.press("j"); await pressSequence(waitingPage, "]", "c"); await pressSequence(waitingPage, "Space", "r"); await expectCurrentLine(waitingPage, "alpha-long", "old", 10); expect(saved).toHaveLength(savesWhileBusy); await expect(waitingPage.locator("#keyboard-guide")).toBeHidden(); answer("finished"); await expect(page.locator("#status")).toHaveText("Answer received"); await expect(waitingPage.locator("#workspace")).toHaveAttribute( "aria-busy", "false", ); await waitingPage.close(); server.close(); server = await startReviewServer({ review: makeEmptyReview(), isCurrent: async () => true, refresh: async () => makeEmptyReview(), ask: async () => "answer", save: (draft) => saved.push(structuredClone(draft)), submit: async () => {}, }); await page.goto(server.url); await expect(page.locator("#status")).toHaveText("Review ready"); await expect(page.locator(".diff-line.keyboard-current")).toHaveCount(0); await expect(page.locator("#keyboard-status")).toContainText("NORMAL"); await expect(page.locator("#keyboard-status")).toContainText("no lines"); await expect(page.locator("#mark-layer-reviewed")).toBeDisabled(); const savesBeforeEmptyActions = saved.length; await page.keyboard.press("j"); await page.keyboard.press("G"); await pressSequence(page, "]", "c"); await pressSequence(page, "]", "u"); await pressSequence(page, "Space", "r"); await pressSequence(page, "Space", "c"); expect(saved).toHaveLength(savesBeforeEmptyActions); await expect(page.locator("#comment-dialog")).not.toHaveAttribute("open", ""); }); test("keeps writes disabled when readiness polling exhausts", async ({ page, }) => { let answer!: (value: string) => void; let askStarted!: () => void; const didStart = new Promise((resolve) => { askStarted = resolve; }); server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => new Promise((resolve) => { answer = resolve; askStarted(); }), save: () => {}, submit: async () => {}, }); await page.goto(server.url); await pressSequence(page, "Space", "a"); await page.locator("#question").fill("Keep the server busy past retries"); await page.locator("#question").press("Shift+Enter"); await didStart; const waitingPage = await page.context().newPage(); await waitingPage.goto(server.url); await waitingPage.waitForTimeout(4_300); await expect(waitingPage.locator("#workspace")).toHaveAttribute( "aria-busy", "true", ); await expect(waitingPage.locator("#status")).toContainText( "Reload this page to retry", ); await expect( waitingPage.getByRole("button", { name: "Refresh" }), ).toBeDisabled(); await expect( waitingPage.getByRole("button", { name: "Mark reviewed" }).first(), ).toBeDisabled(); await expect( waitingPage.getByRole("button", { name: "Close" }), ).toBeEnabled(); answer("finished"); await expect(page.locator("#status")).toHaveText("Answer received"); await waitingPage.reload(); await expect(waitingPage.locator("#status")).toHaveText("Review ready"); await expect( waitingPage.getByRole("button", { name: "Refresh" }), ).toBeEnabled(); await waitingPage.close(); }); test("help close cannot steal focus from a newer question", async ({ page, }) => { server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview(), ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.goto(server.url); await page.keyboard.press("?"); await expect(page.locator("#keyboard-help")).toHaveAttribute("open", ""); await closeKeyboardHelp(page); await pressSequence(page, "Space", "a"); await expect(page.locator("#question")).toBeFocused(); await page .locator("#keyboard-help") .evaluate((dialog) => dialog.dispatchEvent(new Event("close"))); await expect(page.locator("#question")).toBeFocused(); await expect(page.locator("#keyboard-status")).toContainText("TEXT"); await expect(page.locator("#keyboard-return-hint")).toBeVisible(); }); test("renders focused normal, leader, help, and typing states responsively", async ({ page, }, testInfo) => { server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("keyboard-refreshed"), ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.setViewportSize({ width: 1280, height: 800 }); await page.emulateMedia({ colorScheme: "light" }); await page.goto(server.url); const lightBackground = await page .locator("body") .evaluate((element) => getComputedStyle(element).backgroundColor); await expect( page.locator('[data-keyboard-pane="diff"].keyboard-focused'), ).toBeVisible(); await expect(currentLine(page)).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("keyboard-desktop-normal-light.png"), }); await page.keyboard.press("Space"); await expect(page.locator("#keyboard-guide")).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("keyboard-desktop-leader-light.png"), }); await page.keyboard.press("Escape"); await page.keyboard.press("?"); await expect(page.locator("#keyboard-help")).toHaveAttribute("open", ""); await page.screenshot({ path: testInfo.outputPath("keyboard-desktop-help-light.png"), }); await closeKeyboardHelp(page); await currentLine(page).focus(); await pressSequence(page, "Space", "a"); await expect(page.locator("#question")).toBeFocused(); await expect(page.locator("#keyboard-status")).toContainText("TEXT"); await expect(page.locator("#keyboard-context")).toBeHidden(); await expect(page.locator("#keyboard-return-hint")).toBeVisible(); await expect(page.locator("#keyboard-return-hint")).toContainText( "return to diff", ); await page.screenshot({ path: testInfo.outputPath("keyboard-desktop-typing-light.png"), }); await page.keyboard.press("Escape"); await expect(page.locator("#keyboard-return-hint")).toBeHidden(); await page.emulateMedia({ colorScheme: "dark" }); const darkBackground = await page .locator("body") .evaluate((element) => getComputedStyle(element).backgroundColor); expect(darkBackground).not.toBe(lightBackground); await page.screenshot({ path: testInfo.outputPath("keyboard-desktop-normal-dark.png"), }); await page.setViewportSize({ width: 390, height: 760 }); await expect(page.locator('[data-keyboard-pane="layers"]')).toBeHidden(); await expect(page.locator('[data-keyboard-pane="drawer"]')).toBeHidden(); await expect( page.locator('[data-keyboard-pane="diff"].keyboard-focused'), ).toBeVisible(); await expectNoViewportOverflow(page); await page.screenshot({ path: testInfo.outputPath("keyboard-mobile-normal-dark.png"), }); await page.keyboard.press("Space"); await expect(page.locator("#keyboard-guide")).toBeVisible(); await expectNoViewportOverflow(page); await page.screenshot({ path: testInfo.outputPath("keyboard-mobile-leader-dark.png"), }); await page.keyboard.press("Escape"); await page.keyboard.press("?"); await expect(page.locator("#keyboard-help")).toHaveAttribute("open", ""); await expectNoViewportOverflow(page); await page.screenshot({ path: testInfo.outputPath("keyboard-mobile-help-dark.png"), }); await closeKeyboardHelp(page); await pressSequence(page, "Space", "a"); await expect(page.locator("#keyboard-status")).toContainText("TEXT"); await expect(page.locator("#question")).toBeFocused(); await expect(page.locator('[data-keyboard-pane="drawer"]')).toBeVisible(); await expect(page.locator("#keyboard-context")).toBeHidden(); await expect(page.locator("#keyboard-return-hint")).toBeVisible(); await expectNoViewportOverflow(page); await page.screenshot({ path: testInfo.outputPath("keyboard-mobile-typing-dark.png"), }); });