import { expect, test } from "@playwright/test"; import type { Draft, DraftRecovery, Review, ReviewServer } from "../types.js"; const { startReviewServer }: typeof import("../server.ts") = await import( new URL("../../../dist/strata/server.ts", import.meta.url).href ); function review(id = "continuity"): Review { return { snapshot: { id, repoRoot: "/safe/continuity", source: { kind: "working" }, base: "base", head: "head", hunks: [ { id: "change", path: "src/change.ts", header: "@@ -1,2 +1,2 @@ change", lines: [ { kind: "add", text: "new", newLine: 1 }, { kind: "context", text: "same", oldLine: 2, newLine: 2 }, ], }, ], skipped: [], }, plan: { summary: "Continuity", cohorts: [ { title: "Change", layers: [ { id: "layer", title: "Change", summary: "Explain the change", hunks: [{ id: "change", summary: "Two lines" }], }, ], }, ], }, draft: { reviewed: [], findings: [], notes: "" }, }; } let server: ReviewServer | undefined; test.afterEach(async () => { await server?.close(); server = undefined; }); async function state(page: import("@playwright/test").Page) { return page.evaluate(async () => (await fetch("/api/state")).json(), ) as Promise<{ review: Review }>; } const options = { isCurrent: async () => true, ask: async () => "The changed line introduces the new behavior.", submit: async () => {}, }; test("reload never overwrites another tab with an acknowledged but older cache", async ({ page, }) => { server = await startReviewServer({ ...options, review: review(), refresh: async () => review(), save: () => {}, }); await page.goto(server.url); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#notes").fill("Older tab note"); await expect(page.locator("#status")).toHaveText("Draft saved"); const other = await page.context().newPage(); await other.goto(server.url); await other.getByRole("tab", { name: "Feedback", exact: true }).click(); await other.locator("#notes").fill("Authoritative newer tab note"); await expect(other.locator("#status")).toHaveText("Draft saved"); let writes = 0; page.on("request", (request) => { if (request.url().endsWith("/api/draft")) writes++; }); await page.reload(); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#local-recovery summary").click(); await expect(page.locator("#recovery-content")).toContainText( "Older tab note", ); await expect(page.locator("#notes")).toHaveValue( "Authoritative newer tab note", ); await page.waitForTimeout(650); expect(writes).toBe(0); expect((await state(page)).review.draft.notes).toBe( "Authoritative newer tab note", ); await other.close(); }); test("older save acknowledgement cannot erase still-debounced notes from recovery", async ({ page, }) => { server = await startReviewServer({ ...options, review: review(), refresh: async () => review(), save: () => {}, }); let releaseResponse!: () => void; const responseHeld = new Promise((resolve) => { releaseResponse = resolve; }); let responseReady = false; await page.route("**/api/draft", async (route) => { const response = await route.fetch(); responseReady = true; await responseHeld; await route.fulfill({ response }); }); await page.goto(server.url); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#notes").fill("Acknowledged older note"); await expect.poll(() => responseReady).toBe(true); await page.clock.install({ time: 0 }); await page.clock.pauseAt(1000); await page.locator("#notes").fill("Still-debounced newer note"); releaseResponse(); await expect(page.locator("#status")).toHaveText("Draft saved"); const cached = await page.evaluate(() => { const key = Object.keys(sessionStorage).find((key) => key.includes("draft"), ); return JSON.parse(sessionStorage.getItem(key!)!) as Draft; }); expect(cached.notes).toBe("Still-debounced newer note"); await page.reload(); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#local-recovery summary").click(); await expect(page.locator("#recovery-content")).toContainText( "Still-debounced newer note", ); expect((await state(page)).review.draft.notes).toBe( "Acknowledged older note", ); }); test("failed comment save keeps the range visible and Close preserves it", async ({ page, }) => { let recoveries: DraftRecovery[] = []; server = await startReviewServer({ ...options, review: review(), refresh: async () => review(), save: () => {}, saveRecoveries: (saved) => { recoveries = structuredClone(saved); }, }); await page.route("**/api/draft", (route) => route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Comment save unavailable" }), }), ); await page.goto(server.url); for (const line of [1, 2]) { await page .getByRole("button", { name: `Comment on new line ${line} in src/change.ts`, exact: true, }) .click({ modifiers: ["Shift"] }); } await page.locator(".keyboard-current").focus(); await page.keyboard.press("Space"); await page.keyboard.press("c"); await expect(page.locator("#comment-anchor")).toHaveText( "src/change.ts · new:1–2", ); await page.locator("#comment-text").fill("Keep the failed range comment"); await page.getByRole("button", { name: "Add comment", exact: true }).click(); await expect(page.locator("#status")).toContainText( "Comment save unavailable", ); await expect(page.locator("#comment-dialog")).not.toBeVisible(); await expect(page.locator("#findings .finding")).toHaveCount(1); await expect(page.locator("#findings")).toContainText( "Keep the failed range comment", ); await page.locator("#notes").fill("Keep the pending note too"); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Close", exact: true }).click(); await expect(page.locator("#status")).toContainText("Review closed"); expect(recoveries).toHaveLength(1); expect(JSON.parse(recoveries[0]!.text)).toMatchObject({ notes: "Keep the pending note too", findings: [ { hunkId: "change", side: "new", line: 1, endLine: 2, text: "Keep the failed range comment", }, ], }); }); test("failed autosave blocks destructive Refresh and Close persists recovery for another port", async ({ page, }) => { let refreshes = 0; let savedRecoveries: DraftRecovery[] = []; server = await startReviewServer({ ...options, review: review(), refresh: async () => { refreshes++; return review("refreshed"); }, save: () => {}, saveRecoveries: (recoveries) => { savedRecoveries = structuredClone(recoveries); }, }); await page.route("**/api/draft", (route) => route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Autosave unavailable" }), }), ); await page.goto(server.url); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#notes").fill("Keep the failed-save note"); await expect(page.locator("#status")).toContainText("Autosave unavailable"); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.locator("#status")).toContainText("Autosave unavailable"); await expect(page.locator("#notes")).toHaveValue("Keep the failed-save note"); expect(refreshes).toBe(0); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Close", exact: true }).click(); await expect(page.locator("#status")).toContainText("Review closed"); expect(savedRecoveries).toHaveLength(1); expect(JSON.parse(savedRecoveries[0]!.text).notes).toBe( "Keep the failed-save note", ); await server.close(); await page.unroute("**/api/draft"); server = await startReviewServer({ ...options, review: { ...review(), recoveries: savedRecoveries }, refresh: async () => review(), save: () => {}, saveRecoveries: () => {}, }); await page.goto(server.url); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#local-recovery summary").click(); await expect(page.locator("#recovery-content")).toContainText( "Keep the failed-save note", ); page.once("dialog", (dialog) => dialog.accept()); await page .getByRole("button", { name: "Merge local draft", exact: true }) .click(); await expect(page.locator("#status")).toHaveText("Local draft merged"); expect((await state(page)).review.draft.notes).toBe( "Keep the failed-save note", ); }); test("Close bypasses a stuck draft request and ignores its late rejection", async ({ page, }) => { let held: import("@playwright/test").Route | undefined; let recoveries: DraftRecovery[] = []; server = await startReviewServer({ ...options, review: review(), refresh: async () => review(), save: () => {}, saveRecoveries: (value) => { recoveries = value; }, }); await page.route("**/api/draft", (route) => { held = route; }); await page.goto(server.url); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#notes").fill("Pending forever without this backup"); await expect.poll(() => Boolean(held)).toBe(true); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Close", exact: true }).click(); await expect(page.locator("#status")).toContainText("Review closed"); expect(JSON.parse(recoveries[0]!.text).notes).toBe( "Pending forever without this backup", ); await held!.abort(); await page.waitForTimeout(400); await expect(page.locator("#status")).toHaveText( "Review closed. You may close this tab.", ); await expect( page.getByRole("button", { name: "Close", exact: true }), ).toBeDisabled(); }); test("recovery merge cancels notes debounce rather than overwriting recovered notes", async ({ page, }) => { const recovered = { reviewed: [], findings: [], notes: "Recovered note" }; const saved: Draft[] = []; server = await startReviewServer({ ...options, review: { ...review(), recoveries: [ { id: "recovery", snapshotId: "continuity", text: JSON.stringify(recovered), }, ], }, refresh: async () => review(), save: (draft) => { saved.push(structuredClone(draft)); }, saveRecoveries: () => {}, }); await page.route("**/api/draft", async (route) => { await new Promise((resolve) => setTimeout(resolve, 600)); await route.continue(); }); await page.goto(server.url); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#local-recovery summary").click(); await page.locator("#notes").fill("Current local note"); page.once("dialog", (dialog) => dialog.accept()); await page .getByRole("button", { name: "Merge local draft", exact: true }) .click(); await expect(page.locator("#status")).toHaveText("Local draft merged"); await page.waitForTimeout(650); const draft = (await state(page)).review.draft; expect(draft.notes).toContain("Current local note"); expect(draft.notes).toContain("Recovered note"); expect(saved).toHaveLength(1); }); test("a changed snapshot never restores a comment through an accidentally reused hunk ID", async ({ page, }) => { const previous = review(); previous.draft.findings = [ { id: "finding", hunkId: "change", side: "new", line: 1, endLine: 2, severity: "major", text: "Comment on the original block", }, ]; previous.draft.notes = "Keep the note separately"; const changed = review("changed"); changed.snapshot.hunks[0]!.lines[0]!.text = "Different block at the old coordinates"; server = await startReviewServer({ ...options, review: previous, refresh: async () => changed, save: () => {}, saveRecoveries: () => {}, }); await page.goto(server.url); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.locator("#source-label")).toContainText("changed"); await page.getByRole("tab", { name: "Feedback", exact: true }).click(); await page.locator("#local-recovery summary").click(); await expect(page.locator("#recovery-content")).toContainText( "Comment on the original block", ); page.once("dialog", (dialog) => dialog.accept()); await page .getByRole("button", { name: "Merge local draft", exact: true }) .click(); await expect(page.locator("#status")).toContainText("remain in recovery"); const latest = await state(page); expect(latest.review.draft.findings).toEqual([]); expect(latest.review.draft.notes).toBe("Keep the note separately"); expect(latest.review.recoveries).toHaveLength(1); }); test("unchanged Refresh retains Ask history", async ({ page }) => { server = await startReviewServer({ ...options, review: review(), refresh: async () => review(), save: () => {}, }); await page.goto(server.url); await page.getByRole("tab", { name: "Ask", exact: true }).click(); await page.locator("#question").fill("Why this change?"); await page.getByRole("button", { name: "Send", exact: true }).click(); await expect(page.locator("#status")).toHaveText("Answer received"); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.locator("#status")).toHaveText("Review refreshed"); await page.getByRole("tab", { name: "Ask", exact: true }).click(); await expect(page.locator("#ask-panel")).toContainText( "The changed line introduces the new behavior.", ); });