Files
yoi/tools/web-ux/browser-tests/console_live_shell_test.ts
Hare c78f7f7d80 feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts.

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
2026-10-10 15:55:20 +09:00

297 lines
14 KiB
TypeScript

/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Page } from "playwright";
import { Buffer } from "node:buffer";
import {
observePicker,
pickerDiagnostics,
prepareNativePicker,
} from "./console_live_picker_diagnostics.ts";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const route = "/w/console-live-review/workers/W-901-console-live-fixture/console";
// Build outside this test. Allows isolated snapshots without writes to production sources.
const buildRoot = Deno.env.get("WEB_UX_BUILD_ROOT") ?? join(root, "web/workspace/build");
const outputRoot = Deno.env.get("WEB_UX_LIFECYCLE_OUTPUT") ??
join(root, "target/web-ux/console-live-lifecycle");
async function ready(url: string): Promise<void> {
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
await response.body?.cancel();
if (response.ok) return;
} catch { /* owned server startup */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Owned live fixture did not start");
}
type Method = {
method: string;
params?: {
input?: Array<{ kind: string; invocation?: { identity: string; arguments: unknown[] } }>;
};
};
type FixtureState = { methods: Method[]; uploads: string[]; deletes: string[] };
async function fixtureState(base: string): Promise<FixtureState> {
return await (await fetch(`${base}/fixture-state`)).json();
}
async function replaceDraft(page: Page, text: string): Promise<void> {
await page.locator(".cm-content").click();
await page.keyboard.press("Control+a");
await page.keyboard.press("Backspace");
await page.keyboard.type(text);
}
async function chooseReview(page: Page): Promise<void> {
await replaceDraft(page, "/rev");
await page.getByRole("option").filter({ hasText: "review" }).waitFor();
await page.keyboard.press("Tab");
}
Deno.test("production live Console completes, edits/removes typed drafts and stages/cancels attachment adapter", async () => {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
const base = `http://127.0.0.1:${port}`;
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-net",
"--allow-read",
join(root, "tools/web-ux/browser-tests/console_live_fixture_server.ts"),
String(port),
buildRoot,
],
stdout: "null",
stderr: "inherit",
}).spawn();
await Deno.mkdir(outputRoot, { recursive: true });
try {
await ready(`${base}/health`);
const browser = await chromium.launch({ headless: true });
try {
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
const page = await context.newPage();
await prepareNativePicker(page);
await observePicker(page);
page.setDefaultTimeout(5000);
const errors: string[] = [];
const supersededCatalogReads: string[] = [];
const acknowledgedDeletes = new Set<string>();
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(String(error)));
page.on("requestfailed", (request) => {
// Subscription snapshots deliberately supersede the bootstrap GET.
if (
request.url() === `${base}/api/w/console-live-review/workers` &&
request.method() === "GET" &&
request.failure()?.errorText === "net::ERR_ABORTED"
) supersededCatalogReads.push(request.url());
else errors.push(
`failed ${request.method()} ${request.url()} ${request.failure()?.errorText}`,
);
});
page.on("response", (response) => {
if (response.request().method() === "DELETE" && response.status() === 204) {
acknowledgedDeletes.add(response.url());
}
if (response.status() >= 400) {
errors.push(`HTTP ${response.status()} ${response.url()}`);
}
});
try {
assertEquals((await page.goto(base + route))?.status(), 200);
await page.locator(".cm-content[contenteditable=true]").waitFor();
await page.getByText("Ready. Choose a Feature", { exact: false }).waitFor();
const start = await fixtureState(base);
await chooseReview(page);
await page.getByRole("option").filter({ hasText: "brief" }).waitFor();
await page.keyboard.type("m");
await page.getByRole("option").filter({ hasText: "mode=" }).click();
await page.getByRole("option").filter({ hasText: "brief" }).click();
await page.keyboard.type(", count=2, enabled=");
await page.getByRole("option").filter({ hasText: "true" }).click();
await page.keyboard.type(', path="docs/');
await page.getByRole("option").filter({ hasText: "docs/release-notes.md" }).click();
await page.keyboard.type(")");
await page.locator(".composer-typed-chip").waitFor();
await page.screenshot({ path: join(outputRoot, `${theme}-${width}-invocation.png`) });
let current = await fixtureState(base);
assertEquals(current.uploads, start.uploads, "Feature discovery must not stage files");
assertEquals(
current.methods.filter((method) => method.method !== "list_completions"),
start.methods.filter((method) => method.method !== "list_completions"),
"completion must not submit, notify or execute controls",
);
// Pointer chip edit opens readable syntax, keyboard editing stays unexecuted.
await page.locator(".composer-typed-chip").click();
await page.waitForFunction(() =>
document.querySelector(".cm-content")?.textContent?.includes("count=2")
);
for (
let index = 0;
index < ', enabled=true, path="docs/release-notes.md"'.length;
index++
) {
await page.keyboard.press("ArrowLeft");
}
await page.keyboard.press("Backspace");
await page.keyboard.type("3");
await page.waitForFunction(() =>
document.querySelector(".cm-content")?.textContent?.includes("count=3")
);
await page.screenshot({ path: join(outputRoot, `${theme}-${width}-editing.png`) });
await page.keyboard.press("Escape");
await page.keyboard.press("Control+End");
await page.keyboard.type(" ");
await page.locator(".composer-typed-chip").waitFor();
await page.keyboard.press("Backspace");
await page.keyboard.press("Backspace");
assertEquals(await page.locator(".composer-typed-chip").count(), 0);
await page.keyboard.press("Control+z");
await page.locator(".composer-typed-chip").waitFor();
// Cancel a native picker; no upload and no accidental invocation remains.
await replaceDraft(page, "before /att");
await page.getByRole("option").filter({ hasText: "attach" }).waitFor();
const picker = page.waitForEvent("filechooser");
await page.keyboard.press("Enter");
await (await picker).setFiles([]);
assertEquals((await fixtureState(base)).uploads, start.uploads);
assertEquals(await page.locator(".composer-typed-chip").count(), 0);
assertEquals((await page.locator(".cm-content").innerText()).trim(), "before");
await page.screenshot({
path: join(outputRoot, `${theme}-${width}-picker-cancelled.png`),
});
// Same generic adapter, this time choose a synthetic local file.
await page.keyboard.type("/att");
await page.getByRole("option").filter({ hasText: "attach" }).waitFor();
const uploadPicker = page.waitForEvent("filechooser");
await page.keyboard.press("Enter");
await (await uploadPicker).setFiles({
name: "release-notes.md",
mimeType: "text/markdown",
buffer: Buffer.from("Synthetic release notes"),
});
await page.locator(".composer-typed-chip").waitFor();
await page.screenshot({ path: join(outputRoot, `${theme}-${width}-attachment.png`) });
current = await fixtureState(base);
assertEquals(current.uploads.length, start.uploads.length + 1);
await page.keyboard.press("Control+End");
await page.keyboard.press("Backspace");
assertEquals(await page.locator(".composer-typed-chip").count(), 0);
await page.screenshot({ path: join(outputRoot, `${theme}-${width}-deleted.png`) });
// Staged resource is retained through undo, then released when draft is reset.
await page.keyboard.press("Control+z");
await page.locator(".composer-typed-chip").waitFor();
await replaceDraft(page, "literal /review(mode=brief)");
assertEquals(
await page.locator(".composer-typed-chip").count(),
0,
"unselected slash prose stays literal",
);
await page.keyboard.press("Escape");
// Explicit selection becomes the sole structured Feature payload on Submit.
await chooseReview(page);
await page.keyboard.press("Escape");
await page.keyboard.type('mode="thorough", count=3, enabled=false)');
await page.locator(".composer-typed-chip").waitFor();
const release = page.waitForResponse((response) =>
response.request().method() === "DELETE" && response.url().includes("/attachments/")
);
await page.keyboard.press("Control+Enter");
await page.waitForFunction(() => !document.querySelector(".composer-typed-chip"));
const releasedResponse = await release;
assertEquals(releasedResponse.status(), 204);
current = await fixtureState(base);
assert(
current.deletes.includes(current.uploads.at(-1)!),
"accepted draft boundary releases the abandoned uploaded atom",
);
const submissions = current.methods.slice(start.methods.length).filter((method) =>
method.method === "submit"
);
assertEquals(submissions.length, 1);
assertEquals(submissions[0].params?.input?.[0].kind, "feature_invoke");
assertEquals(submissions[0].params?.input?.[0].invocation?.identity, "fixture.review");
assertEquals(submissions[0].params?.input?.[0].invocation?.arguments, [
{ name: "mode", value: { kind: "string", value: "thorough" } },
{ name: "count", value: { kind: "integer", value: 3 } },
{ name: "enabled", value: { kind: "boolean", value: false } },
]);
// Chromium may report ERR_ABORTED after receiving the empty 204 cleanup
// response. Retain that diagnostic, but verify delivery by both response
// status and server DELETE evidence instead of claiming a lost operation.
assert(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
"no page horizontal overflow",
);
const pickerEvidence = await pickerDiagnostics(page);
// Snapshot diagnostics only after the final browser awaits; classification
// and serialization must use the same immutable set of observed errors.
const recordedErrors = [...errors];
const acknowledgedDeleteDiagnostics = recordedErrors.filter((error) =>
[...acknowledgedDeletes].some((url) =>
error === `failed DELETE ${url} net::ERR_ABORTED`
)
);
assertEquals(
recordedErrors.filter((error) => !acknowledgedDeleteDiagnostics.includes(error)),
[],
);
await Deno.writeTextFile(
join(outputRoot, `${theme}-${width}-result.json`),
JSON.stringify(
{
theme,
width,
errors: recordedErrors,
supersededCatalogReads,
acknowledgedDeleteDiagnostics,
acknowledgedDeleteResponseStatus: releasedResponse.status(),
picker: pickerEvidence,
status: "pass",
before: start,
after: current,
},
null,
2,
),
);
} catch (error) {
await page.screenshot({ path: join(outputRoot, `${theme}-${width}-failure.png`) });
await Deno.writeTextFile(
join(outputRoot, `${theme}-${width}-failure.json`),
JSON.stringify(
{
error: String(error),
errors,
body: await page.locator("body").innerText(),
fixture: await fixtureState(base),
},
null,
2,
),
);
throw error;
} finally {
await context.close();
}
}
}
} finally {
await browser.close();
}
} finally {
server.kill("SIGTERM");
await server.status;
}
});