Agent note
01 / 10
diff --git a/site/script.js b/site/script.js
index 04d4964..9a21043 100644
--- a/site/script.js
+++ b/site/script.js
@@ -21,6 +21,7 @@ copyButton?.addEventListener("click", async () => {
copyButton.textContent = "Copied";
copyStatus.textContent = "Command copied";
} catch {
+ copyButton.textContent = "Copy";
copyStatus.textContent = "Copy failed. Select the command and copy it.";
}
@@ -29,6 +30,7 @@ copyButton?.addEventListener("click", async () => {
statusTimer = setTimeout(() => {
copyStatus.classList.remove("is-visible");
copyButton.textContent = "Copy";
+ copyStatus.textContent = "";
}, 2200);
});
@@ -358,6 +360,11 @@ if (demo) {
!shouldHandleFileArrow({
pickerIsOpen,
targetAcceptsText,
+ targetHandlesArrow: Boolean(
+ event.target.closest(
+ ".demo-diff-scroll, .demo-summary-scroll",
+ ),
+ ),
demoHasFocus: demo.contains(document.activeElement),
})
) {
@@ -374,7 +381,11 @@ if (demo) {
});
elements.page.addEventListener("click", (event) => {
- if (!event.target.closest("button, a, input")) {
+ if (
+ !event.target.closest(
+ "button, a, input, .demo-diff-scroll, .demo-summary-scroll",
+ )
+ ) {
demo.focus({ preventScroll: true });
}
});
diff --git a/site/styles.css b/site/styles.css
index 37b80af..c14e148 100644
--- a/site/styles.css
+++ b/site/styles.css
@@ -20,6 +20,7 @@
--paper-deep: #f1ebdf;
--ink: #20231f;
--muted: #73766d;
+ --muted-strong: #62645e;
--quiet: #aaa99f;
--line: #d8d1c4;
--line-dark: #333831;
@@ -454,6 +455,12 @@ code {
scrollbar-width: thin;
}
+.demo-diff-scroll:focus-visible,
+.demo-summary-scroll:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: -2px;
+}
+
.demo-diff-lines {
font: 450 11.5px/1.6 "Geist Mono", monospace;
min-width: max-content;
@@ -473,7 +480,7 @@ code {
}
.demo-diff-row--meta {
- color: #8f8e86;
+ color: var(--muted-strong);
}
.demo-diff-row--hunk {
@@ -545,7 +552,7 @@ code {
.demo-diff-footer {
align-items: center;
border-top: 1px solid var(--line);
- color: var(--muted);
+ color: var(--muted-strong);
display: flex;
font: 500 9px/1 "Geist Mono", monospace;
justify-content: space-between;
@@ -767,6 +774,10 @@ code {
padding: 0 3px;
}
+.demo-picker-search:focus-within {
+ box-shadow: inset 0 0 0 2px var(--blue);
+}
+
.demo-picker-search > span {
font-size: 22px;
}
@@ -782,12 +793,17 @@ code {
width: 100%;
}
+.demo-picker-search input:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: 2px;
+}
+
.demo-picker-search input::placeholder {
color: #a6a49b;
}
.demo-picker-search small {
- color: var(--muted);
+ color: var(--muted-strong);
font: 500 9px/1 "Geist Mono", monospace;
}
@@ -822,7 +838,7 @@ code {
}
.demo-picker-row-index {
- color: var(--quiet);
+ color: var(--muted-strong);
font: 500 9px/1 "Geist Mono", monospace;
}
diff --git a/tests/browser/browser-harness.mjs b/tests/browser/browser-harness.mjs
new file mode 100644
index 0000000..00036f5
--- /dev/null
+++ b/tests/browser/browser-harness.mjs
@@ -0,0 +1,229 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import { createServer as createHttpServer } from "node:http";
+import { createServer as createNetServer } from "node:net";
+import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, extname, join, resolve, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+import { stripVTControlCharacters } from "node:util";
+import { chromium } from "@playwright/test";
+
+export const browserRoot = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../..",
+);
+
+async function unusedPort() {
+ const probe = createNetServer();
+ probe.listen(0, "127.0.0.1");
+ await once(probe, "listening");
+ const address = probe.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ probe.close();
+ await once(probe, "close");
+ return port;
+}
+
+export function parsedViteOutput(outputText) {
+ const text = stripVTControlCharacters(outputText);
+ return {
+ text,
+ url: text.match(/Local:\s+(http:\/\/[^\s]+)/)?.[1],
+ };
+}
+
+export async function startViteServer({ cwd = browserRoot, env = {} } = {}) {
+ const port = await unusedPort();
+ return new Promise((resolveServer, rejectServer) => {
+ const vite = resolve(browserRoot, "node_modules/vite/bin/vite.js");
+ const child = spawn(
+ process.execPath,
+ [
+ vite,
+ "--host",
+ "127.0.0.1",
+ "--port",
+ String(port),
+ "--strictPort",
+ "--force",
+ ],
+ {
+ cwd,
+ env: { ...process.env, ...env },
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ let outputText = "";
+ let log = "";
+ let ready = false;
+
+ const onOutput = (chunk) => {
+ outputText += chunk.toString();
+ const parsed = parsedViteOutput(outputText);
+ log = parsed.text;
+ if (!parsed.url || ready) return;
+ ready = true;
+ resolveServer({
+ log: () => log,
+ stop: async () => {
+ if (child.exitCode !== null) return;
+ child.kill("SIGTERM");
+ await once(child, "exit");
+ },
+ url: parsed.url,
+ });
+ };
+
+ child.stdout.on("data", onOutput);
+ child.stderr.on("data", onOutput);
+ child.once("error", rejectServer);
+ child.once("exit", (code) => {
+ if (!ready) {
+ rejectServer(new Error(`Vite stopped before it was ready (${code}).\n${log}`));
+ }
+ });
+ });
+}
+
+const contentTypes = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".woff2": "font/woff2",
+};
+
+function requestPathname(request) {
+ try {
+ const url = new URL(request.url, "http://127.0.0.1");
+ return decodeURIComponent(url.pathname);
+ } catch {
+ return null;
+ }
+}
+
+function isWithin(directory, file) {
+ return file === directory || file.startsWith(`${directory}${sep}`);
+}
+
+function requestedFile(directory, request) {
+ const pathname = requestPathname(request);
+ if (pathname === null) return null;
+ const file = resolve(
+ directory,
+ pathname === "/" ? "index.html" : `.${pathname}`,
+ );
+ return isWithin(directory, file) ? file : null;
+}
+
+function contentType(file) {
+ return (
+ contentTypes[extname(file).toLowerCase()] ?? "application/octet-stream"
+ );
+}
+
+async function serveStaticFile(directory, request, response) {
+ const file = requestedFile(directory, request);
+ if (!file) {
+ response.writeHead(404).end("Not found");
+ return;
+ }
+ try {
+ const body = await readFile(file);
+ response.writeHead(200, {
+ "content-type": contentType(file),
+ });
+ response.end(body);
+ } catch {
+ response.writeHead(404).end("Not found");
+ }
+}
+
+export async function startStaticServer(directory) {
+ let log = "";
+ const server = createHttpServer((request, response) => {
+ serveStaticFile(directory, request, response).catch((error) => {
+ log += `${error.stack ?? error.message}\n`;
+ response.writeHead(500).end("Server error");
+ });
+ });
+ server.listen(0, "127.0.0.1");
+ await once(server, "listening");
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ return {
+ log: () => log,
+ stop: async () => {
+ server.close();
+ await once(server, "close");
+ },
+ url: `http://127.0.0.1:${port}/`,
+ };
+}
+
+async function saveFailureEvidence(
+ name,
+ page,
+ context,
+ consoleErrors,
+ serverLog,
+) {
+ const evidence = await mkdtemp(join(tmpdir(), "diffsplain-browser-failure-"));
+ await chmod(evidence, 0o700);
+ await Promise.all([
+ page.screenshot({ path: join(evidence, "page.png"), fullPage: true }),
+ context.tracing.stop({ path: join(evidence, "trace.zip") }),
+ writeFile(
+ join(evidence, "browser-errors.json"),
+ JSON.stringify(consoleErrors, null, 2),
+ ),
+ writeFile(join(evidence, "server.log"), serverLog()),
+ ]);
+ return new Error(`${name} failed. Evidence: ${evidence}`);
+}
+
+export async function runInBrowser(
+ name,
+ options,
+ journey,
+ { ignoredConsoleError = () => false, serverLog = () => "" } = {},
+) {
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext(options);
+ const page = await context.newPage();
+ const consoleErrors = [];
+ let traceSaved = false;
+
+ page.on("console", (message) => {
+ if (message.type() === "error") consoleErrors.push(message.text());
+ });
+ page.on("pageerror", (error) => consoleErrors.push(error.message));
+ await context.tracing.start({
+ screenshots: true,
+ snapshots: true,
+ sources: false,
+ });
+
+ try {
+ await journey(page, context);
+ assert.deepEqual(consoleErrors.filter((message) => !ignoredConsoleError(message)), []);
+ } catch (error) {
+ const evidenceError = await saveFailureEvidence(
+ name,
+ page,
+ context,
+ consoleErrors,
+ serverLog,
+ );
+ traceSaved = true;
+ throw new Error(`${evidenceError.message} ${error.message}`, {
+ cause: error,
+ });
+ } finally {
+ if (!traceSaved) await context.tracing.stop();
+ await context.close();
+ await browser.close();
+ }
+}
diff --git a/tests/browser/landing-demo.test.mjs b/tests/browser/landing-demo.test.mjs
new file mode 100644
index 0000000..e12fdd9
--- /dev/null
+++ b/tests/browser/landing-demo.test.mjs
@@ -0,0 +1,403 @@
+import assert from "node:assert/strict";
+import { resolve } from "node:path";
+import test, { after, before } from "node:test";
+import AxeBuilder from "@axe-core/playwright";
+
+import { todoDemoFiles } from "../../site/todo-demo.js";
+import {
+ browserRoot,
+ runInBrowser,
+ startStaticServer,
+} from "./browser-harness.mjs";
+
+const selectedFile = todoDemoFiles.find(
+ (file) => file.path === "src/lib/todoStore.test.ts",
+);
+const selectedDiffLine = selectedFile.patch
+ .split("\n")
+ .find((line) => line.startsWith("+") && !line.startsWith("+++"))
+ .slice(1);
+let server;
+
+function runLandingJourney(name, options, journey) {
+ return runInBrowser(name, options, journey, {
+ serverLog: () => server.log(),
+ });
+}
+
+async function assertFocusRing(locator, label, ringLocator = locator) {
+ const focus = await locator.evaluate((element) => {
+ return {
+ active: element === document.activeElement,
+ focusVisible: element.matches(":focus-visible"),
+ };
+ });
+ const ring = await ringLocator.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ boxShadow: style.boxShadow,
+ outlineStyle: style.outlineStyle,
+ outlineWidth: style.outlineWidth,
+ };
+ });
+ assert.equal(focus.active, true, `${label} should have focus`);
+ assert.equal(focus.focusVisible, true, `${label} should match :focus-visible`);
+ assert.ok(
+ (ring.outlineStyle !== "none" && ring.outlineWidth !== "0px") ||
+ ring.boxShadow !== "none",
+ `${label} should show a focus ring`,
+ );
+}
+
+async function tabTo(page, selector) {
+ for (let count = 0; count < 20; count += 1) {
+ await page.keyboard.press("Tab");
+ const found = await page.evaluate(
+ (candidate) => document.activeElement?.matches(candidate),
+ selector,
+ );
+ if (found) return;
+ }
+ assert.fail(`Keyboard focus did not reach ${selector}`);
+}
+
+function axeFindings(results) {
+ return results.violations.map(({ id, impact, nodes }) => ({
+ id,
+ impact,
+ targets: nodes.flatMap((node) => node.target),
+ }));
+}
+
+async function assertDemoHasNoAxeViolations(page) {
+ const results = await new AxeBuilder({ page })
+ .include("[data-demo]")
+ .analyze();
+ assert.deepEqual(axeFindings(results), []);
+ const passedRules = new Set(results.passes.map(({ id }) => id));
+ for (const rule of ["aria-roles", "button-name", "color-contrast"]) {
+ assert.ok(passedRules.has(rule), `axe should pass ${rule}`);
+ }
+}
+
+async function dispatchSwipe(locator, startX, endX) {
+ await locator.evaluate(
+ (element, points) => {
+ const fire = (type, clientX) => {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperty(event, "changedTouches", {
+ value: [{ clientX }],
+ });
+ element.dispatchEvent(event);
+ };
+ fire("touchstart", points.startX);
+ fire("touchend", points.endX);
+ },
+ { startX, endX },
+ );
+}
+
+before(async () => {
+ server = await startStaticServer(resolve(browserRoot, "site"));
+});
+
+after(async () => {
+ await server?.stop();
+});
+
+test("selects a demo file and shows its matching diff and note", async () => {
+ await runLandingJourney(
+ "landing pointer journey",
+ { viewport: { width: 1280, height: 900 } },
+ async (page) => {
+ await page.goto(server.url);
+ const demo = page.getByRole("region", {
+ name: "Interactive ten-file todo pull request demo",
+ });
+ await demo.waitFor();
+ await page.getByRole("table", { name: "Current file diff" }).waitFor();
+ await page.getByRole("complementary", { name: "Agent note" }).waitFor();
+
+ await page.getByRole("button", { name: /Choose file/ }).click();
+ const dialog = page.getByRole("dialog", { name: "Jump to a file" });
+ await dialog.waitFor();
+ const search = page.getByRole("searchbox", {
+ name: "Search changed files",
+ });
+ await search.fill("todoStore.test");
+ await dialog
+ .getByRole("button")
+ .filter({ hasText: selectedFile.path })
+ .click();
+
+ assert.equal(
+ await page.locator("[data-demo-path]").textContent(),
+ selectedFile.path,
+ );
+ assert.equal(
+ await page.locator("[data-demo-summary-title]").textContent(),
+ selectedFile.summary.title,
+ );
+ assert.match(
+ await page.locator("[data-demo-stats]").textContent(),
+ new RegExp(`\\+${selectedFile.additions}.*−${selectedFile.deletions}`),
+ );
+ assert.ok(
+ (await page.locator("[data-demo-diff]").textContent()).includes(
+ selectedDiffLine,
+ ),
+ );
+ assert.equal(
+ await page.locator("[data-demo-live]").textContent(),
+ `Showing ${selectedFile.path}, file 9 of 10`,
+ );
+ },
+ );
+});
+
+test("reports copy success and failure without stale status", async () => {
+ await runLandingJourney(
+ "landing copy feedback",
+ { viewport: { width: 1280, height: 900 } },
+ async (page) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: {
+ writeText: async (value) => {
+ if (window.__clipboardFailure) {
+ throw new Error("Clipboard denied");
+ }
+ window.__copiedCommand = value;
+ },
+ },
+ });
+ });
+ await page.goto(server.url);
+
+ const copy = page.locator(".copy-button");
+ const status = page.getByRole("status");
+ await copy.click();
+ await page.getByRole("button", { name: "Copied", exact: true }).waitFor();
+ assert.equal(await copy.textContent(), "Copied");
+ assert.equal(await status.textContent(), "Command copied");
+ assert.equal(
+ await page.evaluate(() => window.__copiedCommand),
+ "npx diffsplain@latest react/react --pr 37127",
+ );
+
+ await page.evaluate(() => {
+ window.__clipboardFailure = true;
+ });
+ await copy.click();
+ await page
+ .getByText("Copy failed. Select the command and copy it.", {
+ exact: true,
+ })
+ .waitFor();
+ assert.equal(await copy.textContent(), "Copy");
+ assert.equal(
+ await status.textContent(),
+ "Copy failed. Select the command and copy it.",
+ );
+
+ await page.waitForTimeout(2_300);
+ assert.equal(await status.textContent(), "");
+ assert.equal(
+ await status.evaluate((element) => element.matches(".is-visible")),
+ false,
+ );
+ },
+ );
+});
+
+test("keeps a visible keyboard focus order and announces file changes", async () => {
+ await runLandingJourney(
+ "landing keyboard and accessibility",
+ {
+ reducedMotion: "reduce",
+ viewport: { width: 1280, height: 900 },
+ },
+ async (page) => {
+ await page.goto(server.url);
+ const demo = page.locator("[data-demo]");
+ await tabTo(page, "[data-demo]");
+ await assertFocusRing(demo, "demo");
+
+ const expectedControls = [
+ "Show previous file",
+ /Choose file/,
+ "Show next file",
+ ];
+ for (const name of expectedControls) {
+ await page.keyboard.press("Tab");
+ const control = page.getByRole("button", { name });
+ await assertFocusRing(control, String(name));
+ }
+
+ await page.keyboard.press("Enter");
+ assert.equal(
+ await page.locator("[data-demo-path]").textContent(),
+ todoDemoFiles[1].path,
+ );
+ assert.equal(
+ await page.locator("[data-demo-live]").textContent(),
+ `Showing ${todoDemoFiles[1].path}, file 2 of 10`,
+ );
+
+ await page.keyboard.press("Shift+Tab");
+ const picker = page.getByRole("button", { name: /Choose file/ });
+ await assertFocusRing(picker, "file picker");
+ await page.keyboard.press("Enter");
+ const search = page.getByRole("searchbox", {
+ name: "Search changed files",
+ });
+ await page.waitForFunction(
+ () => document.activeElement?.matches("[data-demo-picker-search]"),
+ );
+ await assertFocusRing(
+ search,
+ "file search",
+ page.locator(".demo-picker-search"),
+ );
+ await search.fill("todoStore.test");
+ await page.keyboard.press("ArrowDown");
+ const selectedRow = page
+ .getByRole("dialog", { name: "Jump to a file" })
+ .getByRole("button")
+ .filter({ hasText: selectedFile.path });
+ await assertFocusRing(selectedRow, "file result");
+ await page.keyboard.press("Enter");
+ await assertFocusRing(picker, "returned file picker");
+ assert.equal(
+ await page.locator("[data-demo-live]").textContent(),
+ `Showing ${selectedFile.path}, file 9 of 10`,
+ );
+
+ await page.keyboard.press("Tab");
+ await assertFocusRing(
+ page.getByRole("button", { name: "Show next file" }),
+ "next file after picker",
+ );
+ await page.keyboard.press("Tab");
+ await assertFocusRing(
+ page.getByRole("region", { name: "Scrollable unified diff" }),
+ "scrollable diff",
+ );
+ await page.keyboard.press("Tab");
+ await assertFocusRing(
+ page.getByRole("region", { name: "Scrollable agent note" }),
+ "scrollable note",
+ );
+
+ await assertDemoHasNoAxeViolations(page);
+ await picker.click();
+ await assertDemoHasNoAxeViolations(page);
+ },
+ );
+});
+
+test("changes one file on touch without firing another control", async () => {
+ await runLandingJourney(
+ "landing touch journey",
+ {
+ hasTouch: true,
+ isMobile: true,
+ viewport: { width: 390, height: 844 },
+ },
+ async (page) => {
+ await page.goto(server.url);
+ await page.evaluate(() => {
+ window.__demoControlClicks = 0;
+ for (const control of document.querySelectorAll(
+ "[data-demo-prev], [data-demo-next], [data-demo-picker-trigger]",
+ )) {
+ control.addEventListener("click", () => {
+ window.__demoControlClicks += 1;
+ });
+ }
+ });
+
+ await dispatchSwipe(page.locator(".demo-summary-scroll"), 300, 140);
+ assert.equal(
+ await page.locator("[data-demo-path]").textContent(),
+ todoDemoFiles[1].path,
+ );
+ assert.equal(
+ await page.locator("[data-demo-live]").textContent(),
+ `Showing ${todoDemoFiles[1].path}, file 2 of 10`,
+ );
+ assert.equal(
+ await page
+ .locator("[data-demo-picker-trigger]")
+ .getAttribute("aria-expanded"),
+ "false",
+ );
+ assert.equal(
+ await page.evaluate(() => window.__demoControlClicks),
+ 0,
+ );
+
+ await dispatchSwipe(page.locator(".demo-diff-scroll"), 300, 140);
+ assert.equal(
+ await page.locator("[data-demo-path]").textContent(),
+ todoDemoFiles[1].path,
+ );
+ assert.equal(
+ await page.evaluate(() => window.__demoControlClicks),
+ 0,
+ );
+ },
+ );
+});
+
+test("keeps click focus and arrow-key scrolling inside scroll regions", async () => {
+ await runLandingJourney(
+ "landing scroll region keyboard scrolling",
+ {
+ reducedMotion: "reduce",
+ viewport: { width: 390, height: 844 },
+ },
+ async (page) => {
+ await page.goto(server.url);
+ const diff = page.getByRole("region", {
+ name: "Scrollable unified diff",
+ });
+ await diff.click();
+ const path = await page.locator("[data-demo-path]").textContent();
+
+ assert.equal(
+ await diff.evaluate((element) => element === document.activeElement),
+ true,
+ );
+ await page.keyboard.press("ArrowRight");
+ await page.waitForFunction(
+ () =>
+ document.querySelector(".demo-diff-scroll")?.scrollLeft > 0,
+ );
+
+ assert.equal(
+ await page.locator("[data-demo-path]").textContent(),
+ path,
+ );
+
+ const note = page.getByRole("region", {
+ name: "Scrollable agent note",
+ });
+ await note.click();
+ assert.equal(
+ await note.evaluate((element) => element === document.activeElement),
+ true,
+ );
+ await page.keyboard.press("ArrowDown");
+ await page.waitForFunction(
+ () =>
+ document.querySelector(".demo-summary-scroll")?.scrollTop > 0,
+ );
+ assert.equal(
+ await page.locator("[data-demo-path]").textContent(),
+ path,
+ );
+ },
+ );
+});
diff --git a/tests/browser/review-journey.test.mjs b/tests/browser/review-journey.test.mjs
index 9e61845..c5cd08d 100644
--- a/tests/browser/review-journey.test.mjs
+++ b/tests/browser/review-journey.test.mjs
@@ -1,31 +1,21 @@
import assert from "node:assert/strict";
-import { spawn } from "node:child_process";
-import { once } from "node:events";
import { existsSync } from "node:fs";
-import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
-import { dirname, join, resolve } from "node:path";
+import { join } from "node:path";
import test, { after, before } from "node:test";
-import { fileURLToPath } from "node:url";
-import { stripVTControlCharacters } from "node:util";
-import { chromium } from "@playwright/test";
-const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+import {
+ parsedViteOutput,
+ runInBrowser,
+ startViteServer,
+} from "./browser-harness.mjs";
+
let fixtureDirectory;
let output;
let server;
-let serverLog = "";
-let serverOutput = "";
let serverUrl;
-function parsedServerOutput(outputText) {
- const text = stripVTControlCharacters(outputText);
- return {
- text,
- url: text.match(/Local:\s+(http:\/\/[^\s]+)/)?.[1],
- };
-}
-
function snapshot(version, files) {
return {
version,
@@ -122,6 +112,14 @@ async function writeSnapshot(value) {
await writeFile(output, `${JSON.stringify(value, null, 2)}\n`);
}
+function runReviewJourney(name, options, journey) {
+ return runInBrowser(name, options, journey, {
+ ignoredConsoleError: (message) =>
+ message.includes("status of 503") || message.includes("status of 404"),
+ serverLog: () => server.log(),
+ });
+}
+
function deferred() {
let resolve;
const promise = new Promise((done) => {
@@ -130,36 +128,6 @@ function deferred() {
return { promise, resolve };
}
-function startServer() {
- return new Promise((resolveServer, rejectServer) => {
- const vite = resolve(root, "node_modules/vite/bin/vite.js");
- server = spawn(process.execPath, [vite, "--host", "127.0.0.1", "--port", "0"], {
- cwd: root,
- env: {
- ...process.env,
- DIFFSPLAIN_LIVE_OUTPUT: output,
- FORCE_COLOR: "1",
- },
- stdio: ["ignore", "pipe", "pipe"],
- });
- const onOutput = (chunk) => {
- serverOutput += chunk.toString();
- const parsed = parsedServerOutput(serverOutput);
- serverLog = parsed.text;
- if (parsed.url) {
- serverUrl = parsed.url;
- resolveServer();
- }
- };
- server.stdout.on("data", onOutput);
- server.stderr.on("data", onOutput);
- server.once("error", rejectServer);
- server.once("exit", (code) => {
- rejectServer(new Error(`Vite stopped before it was ready (${code}).\n${serverLog}`));
- });
- });
-}
-
test("parses Vite's URL when text and color codes cross output chunks", () => {
let outputText = "";
for (const chunk of [
@@ -171,55 +139,9 @@ test("parses Vite's URL when text and color codes cross output chunks", () => {
outputText += chunk;
}
- assert.equal(parsedServerOutput(outputText).url, "http://127.0.0.1:4173/");
+ assert.equal(parsedViteOutput(outputText).url, "http://127.0.0.1:4173/");
});
-async function stopServer() {
- if (!server || server.exitCode !== null) return;
- server.kill("SIGTERM");
- await once(server, "exit");
-}
-
-async function runInBrowser(name, options, journey) {
- const browser = await chromium.launch({ headless: true });
- const context = await browser.newContext(options);
- const page = await context.newPage();
- const consoleErrors = [];
- let traceSaved = false;
- page.on("console", (message) => {
- if (message.type() === "error") consoleErrors.push(message.text());
- });
- page.on("pageerror", (error) => consoleErrors.push(error.message));
- await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
-
- try {
- await journey(page);
- assert.deepEqual(
- consoleErrors.filter(
- (message) =>
- !message.includes("status of 503") &&
- !message.includes("status of 404"),
- ),
- [],
- );
- } catch (error) {
- const evidence = await mkdtemp(join(tmpdir(), "diffsplain-browser-failure-"));
- await chmod(evidence, 0o700);
- await Promise.all([
- page.screenshot({ path: join(evidence, "review.png"), fullPage: true }),
- context.tracing.stop({ path: join(evidence, "trace.zip") }),
- writeFile(join(evidence, "browser-errors.json"), JSON.stringify(consoleErrors, null, 2)),
- writeFile(join(evidence, "server.log"), serverLog),
- ]);
- traceSaved = true;
- throw new Error(`${name} failed: ${error.message}. Evidence: ${evidence}`, { cause: error });
- } finally {
- if (!traceSaved) await context.tracing.stop();
- await context.close();
- await browser.close();
- }
-}
-
async function selectFile(page, search) {
await page.locator(".file-picker-trigger").click();
await page.getByRole("dialog", { name: "Choose a changed file" }).waitFor();
@@ -231,18 +153,24 @@ async function selectFile(page, search) {
before(async () => {
fixtureDirectory = await mkdtemp(join(tmpdir(), "diffsplain-browser-fixture-"));
output = join(fixtureDirectory, "diff-data.json");
- await startServer();
+ server = await startViteServer({
+ env: {
+ DIFFSPLAIN_LIVE_OUTPUT: output,
+ FORCE_COLOR: "1",
+ },
+ });
+ serverUrl = server.url;
});
after(async () => {
- await stopServer();
+ await server?.stop();
if (fixtureDirectory && existsSync(fixtureDirectory)) {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
test("shows error, empty, binary, truncated, and refreshed review states on desktop", async () => {
- await runInBrowser("desktop review journey", { viewport: { width: 1280, height: 800 } }, async (page) => {
+ await runReviewJourney("desktop review journey", { viewport: { width: 1280, height: 800 } }, async (page) => {
await page.goto(serverUrl);
await page.getByText("Snapshot returned 503").waitFor();
@@ -267,7 +195,7 @@ test("shows error, empty, binary, truncated, and refreshed review states on desk
});
test("keeps the newest live snapshot through late responses and faults", async () => {
- await runInBrowser(
+ await runReviewJourney(
"ordered live refresh",
{ viewport: { width: 1280, height: 800 } },
async (page) => {
@@ -400,7 +328,7 @@ test("keeps the newest live snapshot through late responses and faults", async (
});
test("keeps a pending success when a newer refresh fails", async () => {
- await runInBrowser(
+ await runReviewJourney(
"pending successful refresh",
{ viewport: { width: 1280, height: 800 } },
async (page) => {
@@ -453,7 +381,7 @@ test("keeps a pending success when a newer refresh fails", async () => {
});
test("recovers from event faults without resetting the selected file", async () => {
- await runInBrowser(
+ await runReviewJourney(
"event stream recovery",
{ viewport: { width: 1280, height: 800 } },
async (page) => {
@@ -599,7 +527,7 @@ test("recovers from event faults without resetting the selected file", async ()
});
test("runs the full picker and refresh journey at the supported mobile viewport", async () => {
- await runInBrowser(
+ await runReviewJourney(
"mobile review journey",
{ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } },
async (page) => {
diff --git a/tests/landing-demo.test.mjs b/tests/landing-demo.test.mjs
index 7de99f3..c36c1fe 100644
--- a/tests/landing-demo.test.mjs
+++ b/tests/landing-demo.test.mjs
@@ -40,6 +40,7 @@ test("scopes arrow keys to the focused demo", () => {
shouldHandleFileArrow({
pickerIsOpen: false,
targetAcceptsText: false,
+ targetHandlesArrow: false,
demoHasFocus: true,
}),
true,
@@ -48,6 +49,7 @@ test("scopes arrow keys to the focused demo", () => {
shouldHandleFileArrow({
pickerIsOpen: false,
targetAcceptsText: false,
+ targetHandlesArrow: false,
demoHasFocus: false,
}),
false,
@@ -56,6 +58,16 @@ test("scopes arrow keys to the focused demo", () => {
shouldHandleFileArrow({
pickerIsOpen: true,
targetAcceptsText: false,
+ targetHandlesArrow: false,
+ demoHasFocus: true,
+ }),
+ false,
+ );
+ assert.equal(
+ shouldHandleFileArrow({
+ pickerIsOpen: false,
+ targetAcceptsText: false,
+ targetHandlesArrow: true,
demoHasFocus: true,
}),
false,