diff --git a/scripts/browser-proof-execution.mjs b/scripts/browser-proof-execution.mjs index 795762e3..2e255709 100644 --- a/scripts/browser-proof-execution.mjs +++ b/scripts/browser-proof-execution.mjs @@ -69,6 +69,52 @@ function admitBrowserProofReport({admissionArguments, execFile, reportPath, sour return projects; } +// Every page navigation of a browser proof harness runs inside +// withNavigationRequestBarrier, which adds to the page, before the navigation +// starts, a route that matches every URL and only falls back. From then on the +// driver intercepts every request of the page. The handler overrides nothing: +// each request continues unchanged to the next matching route of the test, or +// to the network, and its response is the network's. While any route is in +// place the driver also disables the HTTP cache; the fixture server already +// marks its documents, assets and API results cache-control: no-store. +// +// The barrier closes a race in pinned WebKit (WebKit bug 325840). Every +// fixture document is served with Cross-Origin-Opener-Policy: same-origin, so +// a navigation into one from the initial about:blank, or from a document of +// another origin, needs a browsing context group switch. WebKit starts that +// switch when its network process receives the response, and continues the +// navigation in a new web process. The old web process reports the start of +// its provisional load to the UI process over its own connection, and when +// that report arrives after the switch has begun, the UI process cancels the +// switched navigation ("Load request cancelled"): nothing commits, the page +// keeps its previous document, and goto waits for a load that cannot come. +// The old web process sends that report before it starts loading the +// document; with interception it then holds the document request and sends +// the interception notice over the same connection, and the driver lets the +// request continue only after receiving that notice. The request therefore +// reaches the network process only after the UI process has handled the +// report, and the switch cannot overtake it. +// +// The route stays for the page's lifetime. Removing it would turn +// interception off while the page may still have requests in flight, and in +// pinned Chromium a request issued while interception turns off can be left +// pending forever, leaving the workspace waiting for an API result. Each call +// adds the route again, so a page whose routes a test removed still navigates +// behind the barrier; the first call on a page comes before its first +// navigation, while the initial about:blank has no request in flight. +export async function withNavigationRequestBarrier(page, navigation) { + await page.route(everyNavigationBarrierURL, fallBackFromNavigationBarrier); + return navigation(); +} + +function everyNavigationBarrierURL() { + return true; +} + +function fallBackFromNavigationBarrier(route) { + return route.fallback(); +} + export async function startBrowserServer(binary, options = {}) { const spawnProcess = options.spawnProcess ?? spawn; const readinessTimeoutMs = options.readinessTimeoutMs ?? 30_000; diff --git a/scripts/browser-proof-execution.test.mjs b/scripts/browser-proof-execution.test.mjs index 7748de77..a040c9ce 100644 --- a/scripts/browser-proof-execution.test.mjs +++ b/scripts/browser-proof-execution.test.mjs @@ -6,7 +6,7 @@ import {join} from "node:path"; import {PassThrough} from "node:stream"; import test from "node:test"; -import {executeBrowserProof, runBrowserProofTests, startBrowserServer} from "./browser-proof-execution.mjs"; +import {executeBrowserProof, runBrowserProofTests, startBrowserServer, withNavigationRequestBarrier} from "./browser-proof-execution.mjs"; test("browser proof test execution rejects ambient remote-connect authority", () => { let observed; @@ -130,6 +130,77 @@ test("browser proof preserves execution and cleanup failures", async () => { } }); +test("navigation request barrier is in place before its navigation starts and stays", async () => { + const page = new FakeRoutedPage(); + const value = await withNavigationRequestBarrier(page, async () => { + page.log.push("navigation"); + assert.equal(page.routes.length, 1); + const [{matcher, handler}] = page.routes; + for (const url of ["http://127.0.0.1:41001/", "http://127.0.0.1:41001/assets/workspace.js", "http://127.0.0.1:41002/?other"]) { + assert.equal(matcher(new URL(url)), true); + } + const route = new FakeRoute(); + await handler(route); + assert.deepEqual(route.calls, [["fallback", []]]); + return "response"; + }); + assert.equal(value, "response"); + assert.deepEqual(page.log, ["route", "navigation"]); + assert.equal(page.routes.length, 1); +}); + +test("navigation request barrier stays when its navigation fails", async () => { + const page = new FakeRoutedPage(); + const failure = new Error("navigation failed"); + await assert.rejects(withNavigationRequestBarrier(page, async () => { throw failure; }), (error) => error === failure); + assert.deepEqual(page.log, ["route"]); + assert.equal(page.routes.length, 1); +}); + +test("every navigation adds the barrier again, also after a test removed every route", async () => { + const page = new FakeRoutedPage(); + await withNavigationRequestBarrier(page, async () => {}); + page.routes = []; + await withNavigationRequestBarrier(page, async () => { assert.equal(page.routes.length, 1); }); + await withNavigationRequestBarrier(page, async () => { assert.equal(page.routes.length, 2); }); + assert.deepEqual(page.log, ["route", "route", "route"]); +}); + +test("navigation request barrier starts no navigation when the route cannot be added", async () => { + const failure = new Error("route failed"); + const page = new FakeRoutedPage(failure); + let navigated = false; + await assert.rejects(withNavigationRequestBarrier(page, async () => { navigated = true; }), (error) => error === failure); + assert.equal(navigated, false); +}); + +class FakeRoute { + constructor() { + this.calls = []; + for (const name of ["abort", "continue", "fallback", "fetch", "fulfill"]) { + this[name] = async (...args) => { this.calls.push([name, args]); }; + } + } +} + +// A page that records routes and refuses every way to remove one. +class FakeRoutedPage { + constructor(routeError = null) { + this.log = []; + this.routes = []; + this.routeError = routeError; + for (const name of ["unroute", "unrouteAll"]) { + this[name] = async () => { throw new Error(`the navigation request barrier must not call ${name}`); }; + } + } + + async route(matcher, handler) { + this.log.push("route"); + if (this.routeError) throw this.routeError; + this.routes.push({matcher, handler}); + } +} + class FakeChild extends EventEmitter { constructor(onKill) { super(); diff --git a/scripts/browser-proof-inputs.test.mjs b/scripts/browser-proof-inputs.test.mjs index 06d18001..c1147071 100644 --- a/scripts/browser-proof-inputs.test.mjs +++ b/scripts/browser-proof-inputs.test.mjs @@ -401,7 +401,9 @@ test("workspace navigation excludes provider-falsified lifecycle waits", () => { test("workspace navigation owns waiter rejection before a delayed trigger", async () => { const armed = []; const disarmed = []; + const barrier = []; const page = { + route: async (matcher, handler) => { barrier.push({matcher, handler}); }, mainFrame: () => ({}), evaluate: async () => undefined, on: (event) => { armed.push(event); }, @@ -416,6 +418,8 @@ test("workspace navigation owns waiter rejection before a delayed trigger", asyn }, "unexpected navigation response"), /early response rejection/); assert.deepEqual(armed, ["request", "response", "download"]); assert.deepEqual(disarmed, armed); + // The shared navigation request barrier was in place and stays. + assert.equal(barrier.length, 1); }); test("every local JavaScript import is content-bound by the owner resolution", () => { diff --git a/tests/browser/workspace-navigation-harness.mjs b/tests/browser/workspace-navigation-harness.mjs index 67d25686..2a768e8c 100644 --- a/tests/browser/workspace-navigation-harness.mjs +++ b/tests/browser/workspace-navigation-harness.mjs @@ -2,6 +2,8 @@ import {expect} from "@playwright/test"; import {createHash, randomUUID} from "node:crypto"; import {readFileSync} from "node:fs"; +import {withNavigationRequestBarrier} from "../../scripts/browser-proof-execution.mjs"; + const workspaceNavigationToken = "proofkit.workspace-navigation.scheduled"; // Expected authority comes from source bytes, never a received CSP header. const staticScriptDigest = createHash("sha256").update(readFileSync(new URL("../../internal/kernel/browserdoc/browser.js", import.meta.url))).digest("base64"); @@ -36,7 +38,13 @@ export function isWorkspaceNavigationResponse(candidate, workspaceURL, mainFrame && request.frame() === mainFrame; } -export async function navigateWorkspace(page, workspaceURL, trigger, responseError, heading = "browser.fixture.workspace", securityProfile = "workspace") { +// navigateWorkspace runs one workspace navigation behind the shared +// navigation request barrier, which is in place before the trigger runs. +export function navigateWorkspace(page, workspaceURL, trigger, responseError, heading = "browser.fixture.workspace", securityProfile = "workspace") { + return withNavigationRequestBarrier(page, () => observeWorkspaceNavigation(page, workspaceURL, trigger, responseError, heading, securityProfile)); +} + +async function observeWorkspaceNavigation(page, workspaceURL, trigger, responseError, heading, securityProfile) { const expectedCSP = cspBySecurityProfile[securityProfile]; if (!expectedCSP) throw new Error("Unsupported workspace security profile"); const controller = new AbortController(); diff --git a/tests/browser/workspace-navigation-observation.spec.mjs b/tests/browser/workspace-navigation-observation.spec.mjs index 22d5070a..361b9bfb 100644 --- a/tests/browser/workspace-navigation-observation.spec.mjs +++ b/tests/browser/workspace-navigation-observation.spec.mjs @@ -10,7 +10,7 @@ const rejection = "Workspace observation was not admitted"; const csp = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; worker-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; function protocolModel(realPage, change) { - const state = {winner: "document", current: {location: {href: target}, readyState: "complete"}, status: 200, disposition: "", csp, nosniff: "nosniff", token: true, handles: [], phases: [], listeners: new Map(), waits: new Map()}; + const state = {winner: "document", current: {location: {href: target}, readyState: "complete"}, status: 200, disposition: "", csp, nosniff: "nosniff", token: true, handles: [], phases: [], listeners: new Map(), waits: new Map(), barrierRoutes: []}; change(state); const evaluate = (fn, arg) => runInNewContext(`(${fn.toString()})(argument)`, {document: state.current, argument: arg}); const handle = value => { @@ -55,6 +55,15 @@ function protocolModel(realPage, change) { const response = {request: () => request, url: () => target, ok: () => state.status >= 200 && state.status < 300, headers: () => ({"content-security-policy": state.csp, "x-content-type-options": state.nosniff, "content-disposition": state.disposition})}; const fake = { + // The shared navigation request barrier is in place before anything else + // and is never removed. + async route(matcher, handler) { + expect(state.phases).toEqual([]); + state.phases.push("barrier:route"); + state.barrierRoutes.push({matcher, handler}); + }, + async unroute() { throw new Error("the navigation request barrier must stay"); }, + async unrouteAll() { throw new Error("the navigation request barrier must stay"); }, mainFrame: () => frame, on: (name, listener) => { state.listeners.set(name, listener); }, off: name => { state.listeners.delete(name); }, @@ -80,6 +89,14 @@ function protocolModel(realPage, change) { }; const trigger = async token => { expect([...state.waits.keys()]).toEqual(["response", "event", "document"]); + // The barrier matches every URL and its handler only falls back. + expect(state.barrierRoutes).toHaveLength(1); + const [{matcher, handler}] = state.barrierRoutes; + expect([target, `${target}foreign`, "http://127.0.0.1:41002/assets/workspace.js"].map(url => matcher(new URL(url)))).toEqual([true, true, true]); + const routeCalls = []; + const route = Object.fromEntries(["fallback", "continue", "fulfill", "abort", "fetch"].map(name => [name, async (...args) => { routeCalls.push([name, args]); }])); + await handler(route); + expect(routeCalls).toEqual([["fallback", []]]); if (state.throwTrigger) throw new Error("controlled trigger failure"); if (!state.keepOriginal) state.current = {location: {href: target}, readyState: "complete"}; state.afterDocument?.(); @@ -167,6 +184,9 @@ test("controlled navigation protocol preserves operands, document identity and j expect(state.waits.size).toBe(0); expect(state.listeners.size).toBe(0); expect(state.handles.every(handle => handle.disposed && handle.disposals === 1)).toBe(true); + expect(state.barrierRoutes).toHaveLength(1); + expect(state.phases[0]).toBe("barrier:route"); + expect(state.phases.filter(phase => phase.startsWith("barrier:"))).toEqual(["barrier:route"]); if (item.passed) { expect(state.phases.filter(phase => phase === "domcontentloaded")).toHaveLength(1); expect(state.phases.indexOf("domcontentloaded")).toBeLessThan(state.phases.indexOf("terminal")); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 05e36c01..d66f0583 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -405,6 +405,11 @@ test("workspace navigation admits the exact base and ignores response decoys", a return pending; }; const cleanupPage = { + // The shared navigation request barrier is in place first and stays. + route: async (matcher) => { + expect(matcher(new URL(workspaceURL))).toBe(true); + cleanupEvents.push("barrier-routed"); + }, mainFrame: () => cleanupFrame, evaluate: async () => undefined, on: (event) => { @@ -434,6 +439,7 @@ test("workspace navigation admits the exact base and ignores response decoys", a expect([...cleanupAborted].sort()).toEqual(["document", "navigation", "response"]); expect([...cleanupConsumed].sort()).toEqual(["document", "navigation", "response"]); expect(cleanupEvents).toEqual([ + "barrier-routed", "request-armed", "response-armed", "download-armed", diff --git a/tests/visual/visual-harness.mjs b/tests/visual/visual-harness.mjs index f9c52035..8cc204f9 100644 --- a/tests/visual/visual-harness.mjs +++ b/tests/visual/visual-harness.mjs @@ -5,7 +5,7 @@ import {dirname, resolve, sep} from "node:path"; import {fileURLToPath} from "node:url"; import {expect, test as base} from "@playwright/test"; -import {startBrowserServer} from "../../scripts/browser-proof-execution.mjs"; +import {startBrowserServer, withNavigationRequestBarrier} from "../../scripts/browser-proof-execution.mjs"; import {canonicalRGBADigest, decodePNG} from "./exact-png.mjs"; // The visual reference harness. It captures each view twice, requires both @@ -125,13 +125,14 @@ export const test = base.extend({ // first navigation, the document is reloaded under it so that scripts reading // the scheme at load see it, and it is applied again after the reload, the // last navigation of every captured view; assertEmulation checks it before -// each capture. +// each capture. Both navigations run behind the shared navigation request +// barrier. export async function navigate(page, url, profile, heading, scheme) { await emulate(page, scheme); - const first = await page.goto(url, {waitUntil: "load"}); + const first = await withNavigationRequestBarrier(page, () => page.goto(url, {waitUntil: "load"})); if (first === null || first.status() !== 200) throw new Error("visual view navigation did not return 200"); await emulate(page, scheme); - const response = await page.reload({waitUntil: "load"}); + const response = await withNavigationRequestBarrier(page, () => page.reload({waitUntil: "load"})); if (response === null || response.status() !== 200) throw new Error("visual view reload did not return 200"); await emulate(page, scheme); expect(response.headers()["content-security-policy"]).toBe(expectedCSP[profile]);