import { expect, test } from "@playwright/test"; import { startReviewServer } from "../server.js"; import type { AskRequest, Feedback, Review, ReviewServer } from "../types.js"; function makeReview(id = "snapshot-e2e"): Review { return { snapshot: { id, repoRoot: "/safe/project", source: { kind: "working" }, base: "base-e2e", head: "head-e2e", hunks: [ { id: "hunk-parser", path: "src/parser.ts", header: "@@ -8,2 +8,2 @@ parse", lines: [ { kind: "delete", text: "return unsafe(value);", oldLine: 8 }, { kind: "add", text: "return parse(value);", newLine: 8 }, { kind: "context", text: "}", oldLine: 9, newLine: 9 }, ], }, { id: "hunk-view", path: "src/view.ts", oldPath: "src/old-view.ts", header: "@@ -20 +20 @@ render", lines: [ { kind: "delete", text: "title.textContent = input;", oldLine: 20 }, { kind: "add", text: "title.textContent = '';", newLine: 20, }, ], }, ], skipped: [{ path: "art/logo.bin", reason: "binary" }], }, plan: { summary: "Parse safely, then render text.", cohorts: [ { title: "Runtime", layers: [ { id: "parser", title: "Parse input", summary: "Validate input before use.", hunks: [ { id: "hunk-parser", summary: "Replaces unsafe coercion with parsing.", }, ], flow: ["Raw value", "Parser", "Typed value"], }, { id: "view", title: "Render output", summary: "Render the parsed value.", hunks: [ { id: "hunk-view", summary: "Keeps model-like markup as inert text.", }, ], }, ], }, ], }, draft: { reviewed: [], findings: [], notes: "" }, }; } let server: ReviewServer | undefined; test.afterEach(() => { server?.close(); server = undefined; }); test("reviews every pane, persists drafts, and keeps content inert", async ({ page, }, testInfo) => { const pageErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); const saved = []; server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("snapshot-refreshed"), ask: async (request) => `Plain answer to ${request.question}`, save: (draft) => saved.push(structuredClone(draft)), submit: async () => {}, }); await page.goto(server.url); await expect(page.locator("#source-label")).toContainText("snapshot-e2e"); await expect(page.locator("#view-summary")).toHaveText( "Parse safely, then render text.", ); await expect(page.locator(".hunk")).toHaveCount(2); await expect(page.locator("#omitted-list")).toContainText( "art/logo.bin: binary", ); await expect(page.locator("img")).toHaveCount(0); await expect( page.getByText("", { exact: false }), ).toBeVisible(); await page.getByRole("button", { name: "Parse input" }).click(); await expect(page.locator("#diff-heading")).toHaveText("Parse input"); await expect(page.locator("#view-summary")).toHaveText( "Validate input before use.", ); await expect(page.locator(".hunk")).toHaveCount(1); await expect(page.locator("#explanation")).toHaveText( "Replaces unsafe coercion with parsing.", ); await expect(page.locator(".flow-step")).toHaveText([ "Raw value", "Parser", "Typed value", ]); await expect( page.getByRole("heading", { name: "Dependency order" }), ).toBeVisible(); await expect(page.locator(".flow-description")).toContainText( "Suggested reading sequence, not runtime execution.", ); await page.keyboard.press("]"); await page.keyboard.press("l"); await expect(page.locator("#diff-heading")).toHaveText("Render output"); await page.keyboard.press("["); await page.keyboard.press("l"); await expect(page.locator("#diff-heading")).toHaveText("Parse input"); await page.getByRole("button", { name: "Comment on new line 8" }).click(); await page.locator("#comment-severity").selectOption("major"); await page.locator("#comment-text").fill("Check parser failure behavior."); await page.getByRole("button", { name: "Add comment" }).click(); await expect(page.locator(".finding")).toContainText( "Check parser failure behavior.", ); await page .getByRole("button", { name: "Remove comment on new line 8" }) .click(); await expect(page.locator(".finding")).toHaveCount(0); await page.getByRole("button", { name: "Comment on old line 8" }).click(); await page.locator("#comment-text").fill("Check parser failure behavior."); await page.getByRole("button", { name: "Add comment", exact: true }).click(); await expect(page.locator(".finding")).toContainText("old:8"); await page.getByRole("button", { name: "Mark reviewed" }).click(); await expect(page.locator("#progress-label")).toHaveText("1 / 2 reviewed"); await page.locator("#notes").fill("Run the malformed-input test."); await expect(page.locator("#status")).toHaveText("Draft saved"); await page.getByRole("tab", { name: "Ask" }).click(); await page.locator("#question").fill("What changed?"); await page.getByRole("button", { name: "Send", exact: true }).click(); await expect(page.locator("#chat")).toContainText( "Plain answer to What changed?", ); await expect(page.locator("#chat .chat-question")).toHaveText( "What changed?", ); await page.reload(); await page.getByRole("tab", { name: "Feedback" }).click(); await expect(page.locator("#notes")).toHaveValue( "Run the malformed-input test.", ); await expect(page.locator(".finding")).toContainText( "Check parser failure behavior.", ); await expect(page.locator("#progress-label")).toHaveText("1 / 2 reviewed"); await expect.poll(() => saved.length).toBeGreaterThan(0); await page.screenshot({ path: testInfo.outputPath("strata-workspace.png"), fullPage: true, }); expect(pageErrors).toEqual([]); }); test("confirms refresh, reports errors and stale feedback, then submits", async ({ page, }, testInfo) => { let current = true; const submitted: Feedback[] = []; server = await startReviewServer({ review: makeReview(), isCurrent: async () => current, refresh: async () => makeReview("snapshot-refreshed"), ask: async (request) => { if (request.question === "fail") throw new Error("model unavailable"); return "ok"; }, save: () => {}, submit: async (feedback) => { submitted.push(feedback); }, }); await page.goto(server.url); await page.getByRole("button", { name: "Parse input" }).click(); await page.getByRole("tab", { name: "Ask" }).click(); await page.locator("#question").fill("fail"); await page.getByRole("button", { name: "Send", exact: true }).click(); await expect(page.locator("#status")).toHaveText("model unavailable"); await expect(page.locator("#question")).toHaveValue("fail"); current = false; await page.getByRole("tab", { name: "Feedback" }).click(); await page.locator("#notes").fill("Do not lose this draft."); await expect(page.locator("#status")).toHaveText("Draft saved"); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Send feedback to Pi" }).click(); await expect(page.locator("#status")).toContainText("snapshot is stale"); await expect(page.locator("#notes")).toHaveValue("Do not lose this draft."); current = true; 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"); await expect(page.locator("#notes")).toHaveValue(""); expect(submitted).toHaveLength(1); expect(submitted[0].snapshotId).toBe("snapshot-e2e"); await page.locator("#notes").fill("Discard on explicit refresh."); await expect(page.locator("#status")).toHaveText("Draft saved"); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Refresh" }).click(); await expect(page.locator("#source-label")).toContainText("snapshot-ref"); await expect(page.locator("#notes")).toHaveValue(""); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator(".layer-nav")).toBeHidden(); await expect(page.locator(".detail-region")).toBeHidden(); await page.getByRole("button", { name: "Show review layers" }).click(); await expect(page.locator("#omitted-list")).toContainText( "art/logo.bin: binary", ); await page.getByRole("button", { name: "Hide review layers" }).click(); await page.getByRole("button", { name: "Show review drawer" }).click(); await page.getByRole("tab", { name: "Ask" }).click(); await expect(page.locator("#question")).toBeInViewport(); expect( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), ).toBe(true); await page.screenshot({ path: testInfo.outputPath("strata-phone.png"), fullPage: true, }); }); test("collapses both side panels independently and provides keyboard tabs", async ({ page, }) => { server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("snapshot-refreshed"), ask: async () => "ok", save: () => {}, submit: async () => {}, }); await page.goto(server.url); const initialWidth = await page .locator(".diff-region") .evaluate((element) => element.getBoundingClientRect().width); await page.getByRole("button", { name: "Hide review layers" }).click(); await expect(page.locator(".layer-nav")).toBeHidden(); const withoutLayers = await page .locator(".diff-region") .evaluate((element) => element.getBoundingClientRect().width); expect(withoutLayers).toBeGreaterThan(initialWidth); await page.getByRole("button", { name: "Hide review drawer" }).click(); await expect(page.locator(".detail-region")).toBeHidden(); const diffOnly = await page .locator(".diff-region") .evaluate((element) => element.getBoundingClientRect().width); expect(diffOnly).toBeGreaterThan(withoutLayers); await page.getByRole("button", { name: "Show review drawer" }).click(); const detailsTab = page.getByRole("tab", { name: "Details" }); await expect(detailsTab).toHaveAttribute("aria-selected", "true"); await detailsTab.press("ArrowRight"); await expect(page.getByRole("tab", { name: "Ask" })).toHaveAttribute( "aria-selected", "true", ); await expect(page.locator("#details-panel")).toBeHidden(); await expect(page.locator("#ask-panel")).toBeVisible(); for (const colorScheme of ["light", "dark"] as const) { await page.emulateMedia({ colorScheme }); await page.getByRole("tab", { name: "Ask" }).hover(); const foreground = await page .locator("body") .evaluate((element) => getComputedStyle(element).color); await expect(page.getByRole("tab", { name: "Ask" })).toHaveCSS( "color", foreground, ); } await page.getByRole("tab", { name: "Ask" }).press("End"); await expect(page.getByRole("tab", { name: "Feedback" })).toHaveAttribute( "aria-selected", "true", ); await page.getByRole("button", { name: "Show review layers" }).click(); await expect(page.locator(".layer-nav")).toBeVisible(); }); test("keeps model-generated layer IDs distinct from review-wide draft keys", async ({ page, }) => { const review = makeReview(); review.plan.cohorts[0].layers[0].id = "__all_changes__"; review.plan.cohorts[0].layers[1].id = "__proto__"; server = await startReviewServer({ review, isCurrent: async () => true, refresh: async () => review, ask: async () => "Answer", save: () => {}, submit: async () => {}, }); await page.goto(server.url); await page.getByRole("tab", { name: "Ask" }).click(); for (const name of ["All changes", "Parse input", "Render output"]) { await page.getByRole("button", { name }).click(); await page.locator("#question").fill(`Draft for ${name}`); } await page.reload(); await page.getByRole("tab", { name: "Ask" }).click(); for (const name of ["All changes", "Parse input", "Render output"]) { await page.getByRole("button", { name }).click(); await expect(page.locator("#question")).toHaveValue(`Draft for ${name}`); } }); test("keeps scoped threads, hunk focus, drafts, and async answers distinct", async ({ page, }) => { const requests: AskRequest[] = []; let releaseDelayed: (() => void) | undefined; server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("snapshot-refreshed"), ask: async (request) => { requests.push(structuredClone(request)); if (request.question === "fail") throw new Error("model unavailable"); if (request.question === "Delayed once") { await new Promise((resolve) => { releaseDelayed = resolve; }); } if (request.question === "Long answer") { return Array.from( { length: 160 }, (_, index) => `Answer line ${index}`, ).join("\n"); } return `Answer for ${request.question}`; }, save: () => {}, submit: async () => {}, }); await page.goto(server.url); await page.getByRole("tab", { name: "Ask" }).click(); await expect(page.locator("#ask-scope")).toHaveText("All changes"); await page.locator("#question").fill("All changes draft"); await page.getByRole("button", { name: "Parse input" }).click(); await page.locator("#question").fill("Parser draft"); await page.getByRole("button", { name: "All changes" }).click(); await page.locator("#hunk-hunk-parser").scrollIntoViewIfNeeded(); await page.locator("#hunk-hunk-parser").click(); await expect(page.locator("#ask-focus")).toContainText("src/parser.ts"); await expect(page.locator("#question")).toHaveValue("All changes draft"); await page.locator("#question").press("End"); await page.locator("#question").press("Enter"); await expect(page.locator("#question")).toHaveValue("All changes draft\n"); expect(requests).toHaveLength(0); await page.locator("#question").press("Shift+Enter"); await expect.poll(() => requests.length).toBe(1); expect(requests[0]).toMatchObject({ layerId: null, hunkId: "hunk-parser", question: "All changes draft", history: [], }); await expect(page.locator("#chat")).toContainText( "Answer for All changes draft", ); await expect(page.locator("#question")).toBeFocused(); await page.getByRole("button", { name: "Parse input" }).click(); await expect(page.locator("#question")).toHaveValue("Parser draft"); await page.locator("#question").fill("Parser question"); await page.getByRole("button", { name: "Send", exact: true }).click(); await expect.poll(() => requests.length).toBe(2); expect(requests[1]).toMatchObject({ layerId: "parser", hunkId: "hunk-parser", history: [], }); await page.getByRole("button", { name: "All changes" }).click(); await page.locator("#hunk-hunk-view").click(); await expect(page.locator("#ask-scope")).toHaveText("All changes"); await expect(page.locator("#ask-focus")).toContainText("src/view.ts"); await page.locator("#question").fill("Focused view question"); await page.locator("#question").press("Shift+Enter"); await expect.poll(() => requests.length).toBe(3); expect(requests[2]).toMatchObject({ layerId: null, hunkId: "hunk-view", history: [ { question: "All changes draft", hunkId: "hunk-parser", }, ], }); await page.getByRole("button", { name: "Parse input" }).click(); await page.locator("#question").fill("Delayed once"); await page.locator("#question").press("Shift+Enter"); await expect.poll(() => requests.length).toBe(4); await page.evaluate(() => { (document.querySelector("#ask-form") as HTMLFormElement).requestSubmit(); }); await page.getByRole("button", { name: "Render output" }).click(); await expect(page.locator("#ask-scope")).toHaveText("Render output"); expect( requests.filter((request) => request.question === "Delayed once"), ).toHaveLength(1); releaseDelayed?.(); await expect(page.locator("#status")).toHaveText("Answer received"); await page.getByRole("button", { name: "Parse input" }).click(); await expect(page.locator("#chat")).toContainText("Answer for Delayed once"); await page.locator("#question").fill("fail"); await page.locator("#question").press("Shift+Enter"); await expect(page.locator("#status")).toHaveText("model unavailable"); await expect(page.locator("#question")).toHaveValue("fail"); await expect(page.locator(".chat-error")).toHaveText("model unavailable"); await page.locator("#question").fill("Long answer"); await page.locator("#question").press("Shift+Enter"); await expect(page.locator("#chat")).toContainText("Answer line 159"); expect( await page .locator("#chat") .evaluate((element) => element.scrollHeight > element.clientHeight), ).toBe(true); await page.locator("#chat").evaluate((element) => { element.scrollTop = 0; }); await page.locator("#hunk-hunk-parser").click(); expect( await page.locator("#chat").evaluate((element) => element.scrollTop), ).toBe(0); await page.getByRole("button", { name: "Render output" }).click(); await page.locator("#question").fill("Unsent view draft"); await page.reload(); await page.getByRole("tab", { name: "Ask" }).click(); await page.getByRole("button", { name: "Render output" }).click(); await expect(page.locator("#question")).toHaveValue("Unsent view draft"); await page.getByRole("button", { name: "Parse input" }).click(); await expect(page.locator("#chat")).toContainText("Answer for Delayed once"); await expect(page.locator("#chat")).toContainText("Answer line 159"); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole("button", { name: "Show review drawer" }).click(); await page.getByRole("tab", { name: "Ask" }).click(); await expect(page.locator(".ask-composer")).toBeInViewport(); }); test("renders model layer summaries and hunk references in model order", async ({ page, }) => { const review = makeReview(); review.plan.cohorts[0].layers = [ { id: "ordered", title: "Ordered review", summary: "Read rendering before parsing.", hunks: [ { id: "hunk-view", summary: "Rendering follows parsed input." }, { id: "hunk-parser", summary: "Parsing establishes the input." }, ], }, ]; server = await startReviewServer({ review, isCurrent: async () => true, refresh: async () => makeReview("snapshot-refreshed"), ask: async () => "ok", save: () => {}, submit: async () => {}, }); await page.goto(server.url); await page.getByRole("button", { name: "Ordered review" }).click(); await expect(page.locator("#view-summary")).toHaveText( "Read rendering before parsing.", ); await expect(page.locator(".file-name")).toHaveText([ "src/old-view.ts → src/view.ts", "src/parser.ts", ]); }); test("rejects stale tabs without overwriting their notes", async ({ page }) => { const saved: Review["draft"][] = []; server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("snapshot-refreshed"), ask: async () => "ok", save: (draft) => saved.push(structuredClone(draft)), submit: async () => {}, }); const secondTab = await page.context().newPage(); await page.goto(server.url); await secondTab.goto(server.url); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Refresh" }).click(); await expect(page.locator("#source-label")).toContainText("snapshot-ref"); await secondTab.getByRole("tab", { name: "Feedback" }).click(); await secondTab.locator("#notes").fill("Keep this stale-tab note."); await expect(secondTab.locator("#status")).toContainText( "Review changed in another tab", ); await expect(secondTab.locator("#notes")).toHaveValue( "Keep this stale-tab note.", ); const draftKeys = await secondTab.evaluate(() => Object.keys(sessionStorage)); expect(draftKeys.some((key) => key.endsWith(":snapshot-e2e"))).toBe(true); expect(draftKeys.some((key) => key.endsWith(":snapshot-refreshed"))).toBe( false, ); await secondTab.waitForTimeout(400); expect(saved).toHaveLength(0); await secondTab.close(); }); test("keeps navigation available while asking and recovers initial busy pages", async ({ page, }) => { let answer!: (value: string) => void; let started!: () => void; const didStart = new Promise((resolve) => { started = resolve; }); server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("snapshot-refreshed"), ask: async () => new Promise((resolve) => { answer = resolve; started(); }), save: () => {}, submit: async () => {}, }); await page.goto(server.url); await page.getByRole("button", { name: "Parse input" }).click(); await page.getByRole("tab", { name: "Ask" }).click(); await page.locator("#question").fill("Wait"); await page.getByRole("button", { name: "Send", exact: true }).click(); await didStart; const waitingTab = await page.context().newPage(); await waitingTab.goto(server.url); await expect( waitingTab.getByRole("button", { name: "Refresh" }), ).toBeDisabled(); await waitingTab.getByRole("tab", { name: "Ask" }).click(); await expect( waitingTab.getByRole("button", { name: "Send", exact: true }), ).toBeDisabled(); await expect( waitingTab.getByRole("button", { name: "Mark reviewed" }).first(), ).toBeDisabled(); await expect(waitingTab.getByRole("button", { name: "Close" })).toBeEnabled(); await waitingTab.getByRole("button", { name: "Parse input" }).click(); await expect(waitingTab.locator("#diff-heading")).toHaveText("Parse input"); answer("done"); await expect(page.locator("#chat")).toContainText("done"); await expect( waitingTab.getByRole("button", { name: "Refresh" }), ).toBeEnabled(); await waitingTab.close(); }); test("retains a debounced draft across immediate reload and cancels in-flight asks", async ({ page, }) => { const saved: Review["draft"][] = []; let askAborted = false; let askStarted!: () => void; const didStart = new Promise((resolve) => { askStarted = resolve; }); server = await startReviewServer({ review: makeReview(), isCurrent: async () => true, refresh: async () => makeReview("snapshot-refreshed"), ask: async (_request, signal) => new Promise((_resolve, reject) => { askStarted(); signal.addEventListener("abort", () => { askAborted = true; reject(new Error("aborted")); }); }), save: (draft) => saved.push(structuredClone(draft)), submit: async () => {}, }); await page.goto(server.url); await page.getByRole("tab", { name: "Feedback" }).click(); await page.locator("#notes").pressSequentially("Debounced note"); await page.waitForTimeout(100); expect(saved).toHaveLength(0); await page.reload(); await expect(page.locator("#notes")).toHaveValue("Debounced note"); await expect.poll(() => saved.length).toBe(1); await page.getByRole("button", { name: "Parse input" }).click(); await page.getByRole("tab", { name: "Ask" }).click(); await page.locator("#question").fill("Cancel me"); await page.getByRole("button", { name: "Send", exact: true }).click(); await didStart; await page.reload(); await expect.poll(() => askAborted).toBe(true); await expect(page.locator("#status")).toHaveText("Review ready"); }); test("keeps the dark context rail visible while scrolling a long diff", async ({ page, }, testInfo) => { const review = makeReview(); review.snapshot.hunks[0].lines.push( ...Array.from({ length: 100 }, (_, i) => ({ kind: "context" as const, text: `const item${i} = ${i};`, oldLine: i + 10, newLine: i + 10, })), ); server = await startReviewServer({ review, isCurrent: async () => true, refresh: async () => review, ask: async () => "answer", save: () => {}, submit: async () => {}, }); await page.emulateMedia({ colorScheme: "dark" }); await page.goto(server.url); await expect(page.locator("#status")).toHaveText("Review ready"); await page .locator('.hunk[data-hunk-id="hunk-view"]') .scrollIntoViewIfNeeded(); await expect(page.locator("#current-path")).toHaveText("src/view.ts"); await expect(page.locator("#explanation")).toBeInViewport(); expect( await page.locator(".diff-region").evaluate((el) => el.scrollTop), ).toBeGreaterThan(100); expect(await page.evaluate(() => window.scrollY)).toBe(0); await page.screenshot({ path: testInfo.outputPath("strata-dark-scroll.png"), }); });