fix: navigate before loading page data

This commit is contained in:
2026-10-13 19:56:38 +09:00
parent a82e70047a
commit 26ab6130e0
119 changed files with 12748 additions and 11071 deletions
@@ -0,0 +1,496 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { type Browser, chromium, type Page, type Request, type Route } from "npm:playwright@1.59.1";
import { loadScenario } from "../src/scenario.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
// Explicit browser boundary: production static assets + synthetic loopback API.
// No real Backend auth, Worker execution, or application test hooks.
// The caller supplies the built workspace via the standard suite layout; for isolated
// runs, copy this suite into a tree whose web/workspace/build points at isolated assets.
// This suite NEVER runs a build, mutates assets, or falls back to another static root.
const staticRoot = new URL("../../../web/workspace/build/", import.meta.url).pathname;
const output = new URL("../../../.yoi/dev/navigation-visual/", import.meta.url).pathname;
const phase = "after";
const listPath = "/w/tickets/tickets";
const detailPath = "/w/tickets/tickets/T-2-read-only-investigation";
const otherDetailPath = "/w/tickets/tickets/T-3-independent-review-before-merge";
async function withFixture(run: (browser: Browser, base: string) => Promise<void>) {
// A missing build is an explicit failure; no alternate root or implicit build.
await Deno.stat(`${staticRoot}index.html`);
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
const scenario = await loadScenario(scenarioPath);
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}`;
scenario.processes![0].args![4] = String(port);
scenario.processes![0].args![5] = staticRoot;
scenario.processes![0].readyUrl = `${base}/health`;
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
`${output}${phase}/logs/${port}`,
[],
);
let browser: Browser | null = null;
try {
browser = await chromium.launch({ headless: true });
await run(browser, base);
} finally {
await browser?.close();
assertEquals(await stopOwnedProcesses(processes), [], "owned fixture cleanup");
}
}
async function bounded<T>(promise: Promise<T>): Promise<T> {
let timer: number | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Response gate timed out")), 15_000);
}),
]);
} finally {
clearTimeout(timer);
}
}
// Fetch the real synthetic response, but do not deliver it to the browser until release.
// held means response bytes exist in Playwright, NOT requestfinished in the browser.
async function responseGate(
page: Page,
pattern: string | RegExp,
matches: (request: Request) => boolean = () => true,
replacement?: { status: number; body: string; contentType: string },
transform?: (body: Record<string, unknown>) => unknown,
) {
const held = Promise.withResolvers<void>();
const releaseSignal = Promise.withResolvers<void>();
const delivered = Promise.withResolvers<void>();
let closed = true;
let intercepted = false;
let browserFinished = false;
let gatedRequest: Request | undefined;
const onFinished = (request: Request) => {
if (request === gatedRequest) browserFinished = true;
};
page.on("requestfinished", onFinished);
const handler = async (route: Route) => {
if (intercepted || !matches(route.request())) return await route.fallback();
intercepted = true;
gatedRequest = route.request();
try {
const response = replacement ? undefined : await route.fetch();
const json = transform && response ? transform(await response.json()) : undefined;
held.resolve();
await releaseSignal.promise;
if (replacement) await route.fulfill(replacement);
else await route.fulfill({ response, ...(json === undefined ? {} : { json }) });
delivered.resolve();
} catch (error) {
held.reject(error);
delivered.reject(error);
}
};
await page.route(pattern, handler);
// Attach handlers immediately so cancellation during teardown isn't an unhandled rejection.
void held.promise.catch(() => {});
void delivered.promise.catch(() => {});
return {
held: () => bounded(held.promise),
assertClosed() {
assert(intercepted, "API response must be intercepted first");
assert(closed, "response gate must remain CLOSED during navigation assertions");
assertEquals(browserFinished, false, "browser must not have finished the held request");
},
release() {
closed = false;
releaseSignal.resolve();
},
delivered: () => bounded(delivered.promise),
async dispose() {
closed = false;
releaseSignal.resolve();
await page.unroute(pattern, handler);
page.off("requestfinished", onFinished);
},
};
}
type Gate = Awaited<ReturnType<typeof responseGate>>;
async function loading(page: Page, gate: Gate, path: string, title: RegExp, updating = false) {
await gate.held();
gate.assertClosed();
await page.waitForURL((url) => url.pathname + url.search === path, { timeout: 5_000 });
await page.getByRole("heading", { name: title, level: 1 }).waitFor({ timeout: 5_000 });
await page.getByRole("status").filter({ hasText: updating ? /^Updating…$/ : /Loading/ })
.first().waitFor({ timeout: 5_000 });
gate.assertClosed();
}
// Synthetic links exercise the public SvelteKit delegated link handler, not a full reload.
// Used only where the fixture has no product link (cross-workspace and stale slug scenarios).
async function navigate(page: Page, path: string) {
await page.evaluate((path) => {
const link = document.createElement("a");
link.href = path;
link.dataset.sveltekitPreloadData = "off";
document.body.append(link);
link.click();
link.remove();
}, path);
}
async function clickTickets(page: Page) {
// Actual product sidebar link, even when the mobile sidebar is folded.
await page.locator("a.sidebar-link").filter({ hasText: /^Tickets$/ })
.evaluate((link) => (link as HTMLAnchorElement).click());
}
async function paint(page: Page) {
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
}
async function capture(page: Page, name: string, gate?: Gate) {
gate?.assertClosed();
await paint(page);
const directory = `${output}${phase}`;
await Deno.mkdir(directory, { recursive: true });
await page.screenshot({ path: `${directory}/${name}.png`, fullPage: true });
// Safe, fixture-only evidence. Deliberately no cookies, storage, headers, trace or HAR.
const context = await page.evaluate(() => ({
path: location.pathname + location.search,
title: document.title,
headings: [...document.querySelectorAll("h1")].map((node) => node.textContent),
statuses: [...document.querySelectorAll('[role="status"]')].map((node) => node.textContent),
liveRegions: [...document.querySelectorAll('[aria-live="polite"]')].map((node) =>
node.textContent
),
alerts: [...document.querySelectorAll('[role="alert"]')].map((node) => node.textContent),
viewport: { width: innerWidth, height: innerHeight },
theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
pageOverflow: document.documentElement.scrollWidth > innerWidth,
}));
assertEquals(context.pageOverflow, false, "navigation state must not overflow viewport");
await Deno.writeTextFile(
`${directory}/${name}.json`,
JSON.stringify(
{
...context,
responseGate: gate ? "closed" : "resolved",
staticRoot,
boundary: "synthetic loopback fixture; no real Backend authentication",
},
null,
2,
),
);
gate?.assertClosed();
}
async function rows(page: Page) {
await page.locator("a.ticket-list-row").first().waitFor();
}
async function search(page: Page, query: string) {
await page.getByRole("searchbox", { name: "Search tickets", exact: true }).fill(query);
await page.getByRole("button", { name: "Search", exact: true }).click();
}
const queryPattern = "**/api/w/tickets/tickets/query";
Deno.test("immediate navigation: Orchestrator -> Tickets -> detail before responses, visual matrix", async () => {
await withFixture(async (browser, base) => {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390, 320]) {
const context = await browser.newContext({
colorScheme,
viewport: { width, height: 900 },
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
await page.goto(`${base}/w/tickets/orchestrator`);
await page.locator(".orchestrator-assignment").first().waitFor();
const listGate = await responseGate(page, queryPattern);
try {
await clickTickets(page);
await loading(page, listGate, listPath, /^Tickets$/);
assertEquals(await page.locator(".orchestrator-assignment").count(), 0);
await capture(page, `${colorScheme}-${width}-tickets-gated`, listGate);
listGate.release();
await rows(page);
await capture(page, `${colorScheme}-${width}-tickets-resolved`);
} finally {
await listGate.dispose();
}
const detailGate = await responseGate(page, "**/api/w/tickets/tickets/T-2");
try {
await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click();
await loading(page, detailGate, detailPath, /^Ticket$/);
assertEquals(await page.locator("a.ticket-list-row").count(), 0);
assertEquals(
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
0,
);
await capture(page, `${colorScheme}-${width}-detail-gated`, detailGate);
detailGate.release();
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
.waitFor();
await capture(page, `${colorScheme}-${width}-detail-resolved`);
} finally {
await detailGate.dispose();
}
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
});
});
Deno.test("immediate navigation: query and back/forward commit URLs with response gates closed", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}${listPath}`);
await rows(page);
const open = await responseGate(page, queryPattern);
try {
await search(page, "state:closed");
await loading(page, open, `${listPath}?q=state%3Aclosed`, /^Tickets$/, true);
open.release();
await page.locator(".ticket-list-key").filter({ hasText: /^T-4$/ }).waitFor();
} finally {
await open.dispose();
}
for (const direction of ["back", "forward"] as const) {
const gate = await responseGate(page, queryPattern);
try {
// Don't await goBack/goForward's optional load lifecycle while API is gated.
await page.evaluate(
(direction) => direction === "back" ? history.back() : history.forward(),
direction,
);
const path = direction === "back" ? listPath : `${listPath}?q=state%3Aclosed`;
await loading(page, gate, path, /^Tickets$/, true);
await capture(page, `query-${direction}-gated`, gate);
gate.release();
await page.getByRole("searchbox", { name: "Search tickets", exact: true })
.waitFor();
await page.waitForFunction(
(value) =>
(document.querySelector('input[type="search"]') as HTMLInputElement)?.value === value,
direction === "back" ? "state:open" : "state:closed",
);
await rows(page);
if (direction === "forward") {
assertEquals(await page.locator(".ticket-list-key").allTextContents(), ["T-4"]);
}
} finally {
await gate.dispose();
}
}
});
});
Deno.test("immediate navigation: rapid A -> B rejects late A data and stale canonical redirects", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}${listPath}`);
await rows(page);
// First canonical A (late data), then an obsolete slug A (late canonical redirect).
for (const pathA of [detailPath, "/w/tickets/tickets/T-2-obsolete-title"]) {
const gateA = await responseGate(page, "**/api/w/tickets/tickets/T-2");
const gateB = await responseGate(page, "**/api/w/tickets/tickets/T-3");
try {
await navigate(page, pathA);
await loading(page, gateA, pathA, /^Ticket$/);
await navigate(page, otherDetailPath);
await loading(page, gateB, otherDetailPath, /^Ticket$/);
gateA.assertClosed();
gateB.release();
await page.getByRole("heading", { name: "Independent review before merge", exact: true })
.waitFor();
const response = page.waitForResponse((response) =>
new URL(response.url()).pathname === "/api/w/tickets/tickets/T-2"
);
gateA.release();
await response;
await gateA.delivered();
await paint(page);
assertEquals(new URL(page.url()).pathname, otherDetailPath);
assert(
await page.getByRole("heading", {
name: "Independent review before merge",
exact: true,
}).isVisible(),
);
assertEquals(
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
.count(),
0,
);
await capture(
page,
pathA === detailPath
? "late-old-response-resolved"
: "stale-canonical-redirect-resolved",
);
} finally {
await gateA.dispose();
await gateB.dispose();
}
await navigate(page, listPath);
await rows(page);
}
});
});
Deno.test("immediate navigation: slow workspace switch clears old identity and permission projection", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
// Same existing fixture server, distinct identities and explicit opposite permissions.
await page.route("**/api/w/*/workspace", async (route) => {
const response = await route.fetch();
const body = await response.json();
const old = body.workspace_id === "tickets";
body.display_name = old ? "Old privileged workspace" : "New read-only workspace";
body.permissions = {
manage_repositories: old,
manage_secrets: old,
manage_runtimes: old,
delete_workspace: old,
};
await route.fulfill({ response, json: body });
});
await page.goto(`${base}${detailPath}`);
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
await page.getByText("Old privileged workspace", { exact: true }).first().waitFor();
assert(
await page.getByRole("button", { name: "Release assignment", exact: true }).isVisible(),
);
// route.fetch bypasses other Playwright handlers: transform this held response too.
const gate = await responseGate(
page,
"**/api/w/tickets-readonly/workspace",
() => true,
undefined,
(body) => ({ ...body, display_name: "New read-only workspace" }),
);
const target = detailPath.replace("/w/tickets/", "/w/tickets-readonly/");
try {
await navigate(page, target);
await loading(page, gate, target, /^Workspace$/);
assertEquals(await page.getByText("Old privileged workspace", { exact: true }).count(), 0);
assertEquals(
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
0,
);
assertEquals(
await page.locator('a[href="/w/tickets/settings"], a[href="/w/tickets"]').count(),
0,
);
await capture(page, "workspace-switch-gated", gate);
gate.release();
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
await page.getByText("New read-only workspace", { exact: true }).first().waitFor();
assertEquals(
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
0,
);
await capture(page, "workspace-switch-resolved");
} finally {
await gate.dispose();
}
});
});
Deno.test("immediate navigation: cold load and unrelated global catalog response cannot block destination", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
const catalog = await responseGate(page, /\/api\/workspaces(?:\?|$)/);
const tickets = await responseGate(page, queryPattern);
try {
await page.goto(`${base}${listPath}`, { waitUntil: "domcontentloaded" });
await catalog.held();
await loading(page, tickets, listPath, /^Tickets$/);
catalog.assertClosed();
await capture(page, "cold-load-gated", tickets);
tickets.release();
await rows(page);
// Global catalog remains unavailable while the page is fully usable.
catalog.assertClosed();
const detail = await responseGate(page, "**/api/w/tickets/tickets/T-2");
try {
await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click();
await loading(page, detail, detailPath, /^Ticket$/);
catalog.assertClosed();
detail.release();
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
.waitFor();
catalog.assertClosed();
await capture(page, "global-catalog-delayed-detail-resolved");
} finally {
await detail.dispose();
}
catalog.release();
} finally {
await catalog.dispose();
await tickets.dispose();
}
});
});
Deno.test("immediate navigation: failed read stays at destination and gated Retry recovers", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/orchestrator`);
await page.locator(".orchestrator-assignment").first().waitFor();
const failure = await responseGate(page, queryPattern, () => true, {
status: 503,
contentType: "application/json",
body: JSON.stringify({ message: "Navigation fixture unavailable" }),
});
try {
await clickTickets(page);
await loading(page, failure, listPath, /^Tickets$/);
failure.release();
await page.getByRole("alert").filter({
hasText: /Ticket Browser API request failed with HTTP 503/,
})
.waitFor();
assertEquals(new URL(page.url()).pathname, listPath);
assertEquals(
await page.getByText("No tickets match this query.", { exact: true }).count(),
0,
);
await capture(page, "read-error");
} finally {
await failure.dispose();
}
const retry = await responseGate(page, queryPattern);
try {
await page.getByRole("button", { name: "Retry", exact: true }).click();
// Product Retry performs a local query read, not SvelteKit invalidation.
// Its polite live-region status must be visible while that response stays CLOSED.
await retry.held();
retry.assertClosed();
assertEquals(new URL(page.url()).pathname, listPath);
await page.getByRole("heading", { name: "Tickets", level: 1, exact: true }).waitFor();
await page.locator('.ticket-list-page-state[aria-live="polite"]')
.filter({ hasText: /^Loading…$/ }).waitFor();
retry.assertClosed();
await capture(page, "read-retry-gated", retry);
retry.release();
await rows(page);
assertEquals(await page.getByRole("alert").count(), 0);
await capture(page, "read-retry-resolved");
} finally {
await retry.dispose();
}
});
});
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
},
"fmt": {
"lineWidth": 100,