repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/strata/__e2e__/review.spec.ts
Rawimport { 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 = '<img src=x onerror=alert(1)>';",
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) =>
`<strong>Plain answer to ${request.question}</strong>`,
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("<img src=x onerror=alert(1)>", { 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(
"<strong>Plain answer to What changed?</strong>",
);
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<void>((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<void>((resolve) => {
started = resolve;
});
server = await startReviewServer({
review: makeReview(),
isCurrent: async () => true,
refresh: async () => makeReview("snapshot-refreshed"),
ask: async () =>
new Promise<string>((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<void>((resolve) => {
askStarted = resolve;
});
server = await startReviewServer({
review: makeReview(),
isCurrent: async () => true,
refresh: async () => makeReview("snapshot-refreshed"),
ask: async (_request, signal) =>
new Promise<string>((_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"),
});
});