Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ae283b5
build(go): build with Go 1.27.3 to remove a reachable standard-librar…
iperev Oct 21, 2026
e672f6b
ci(platform): verify Linux arm64 on native ARM runners
iperev Oct 22, 2026
c659893
test(browser): gate rendered views on owner-admitted reference digests
iperev Oct 23, 2026
23697c6
test(browser): assert media emulation in the document under test
iperev Oct 23, 2026
be34b7f
fix(test): give spawned CLI children their input as a file, not a socket
iperev Oct 24, 2026
7562aa7
fix(adapter): give the generated adapter's CLI child its input as a file
iperev Oct 24, 2026
69c475c
fix(gate): run Go tests without replaying cached verdicts
iperev Oct 24, 2026
b3ddafe
docs(backlog): park differential closeout on its measured cost
iperev Oct 24, 2026
19d0457
fix(deps): lock fast-uri 3.1.9 to clear four advisories
iperev Oct 24, 2026
0db9da2
fix(test): stop the npm absence cancellation carrier from losing its …
iperev Oct 24, 2026
2773feb
refactor(ui): define one design-token owner for static and workspace …
iperev Oct 24, 2026
3937293
fix(test): hold navigation requests until WebKit reports the load start
iperev Oct 24, 2026
27ff919
fix: retain qualified execution boundary in browser witness chain
iperev Oct 25, 2026
3e365da
chore(integration): align conflict carriers with verified predecessor
iperev Oct 25, 2026
4da2ca0
chore(integration): include verified predecessor
iperev Oct 25, 2026
e4be08f
fix(integration): retain reviewed owner projections after predecessor…
iperev Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions scripts/browser-proof-execution.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 72 additions & 1 deletion scripts/browser-proof-execution.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions scripts/browser-proof-inputs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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); },
Expand All @@ -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", () => {
Expand Down
10 changes: 9 additions & 1 deletion tests/browser/workspace-navigation-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 21 additions & 1 deletion tests/browser/workspace-navigation-observation.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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); },
Expand All @@ -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?.();
Expand Down Expand Up @@ -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"));
Expand Down
6 changes: 6 additions & 0 deletions tests/browser/workspace.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions tests/visual/visual-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand Down
Loading