open-standards/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs
Transaction Science cb766e7166 room page: take the endpoints a link names only on the page's own origin
The room client took its relay, room service, fixture, WebTransport URL,
certificate hashes and issuer directory from its query string as given, and
followed a relay's GOAWAY to any URI. A crafted link and a press of Join would
send a joiner's camera and microphone to another origin.

- web/room/endpoint.js: an endpoint is taken only on the page's own origin
  (`https://<host>`, or `wss://<host>` for a relay), never with user
  information; a page served from the loopback interface (a fixture page, the
  e2e harness) takes loopback addresses alone.
- app.js: `service`, `ws`, `wt`, `fixture` and `issuer_directory` are held to
  it; `hashes` only on a loopback page. A refused endpoint is recorded
  (`snapshot().refused`) and shown, Join stays off, and the page connects to
  nothing: `enter()` checks the relay before it asks for the camera, a grant or
  a session. A GOAWAY naming another origin is refused: the page closes its
  session and says so.
- web/room/endpoint.test.mjs (run by e2e/run.sh): own origin, another origin,
  scheme-relative, the page's host as user information, javascript:, a plain
  scheme on a secure page, an HTTP page, and loopback for the harness.
- e2e scenario `origin`: a foreign `?ws=` is refused at Join and nothing
  connects (no WebSocket, request, grant or camera); a foreign `?service=`,
  scheme-relative, behind user information or as javascript:, is refused with
  Join off; a GOAWAY to another origin is refused and the session ends. The
  `service` scenario checks that every request and WebSocket of every page
  stays on the page's origin, as the production `connect-src` requires.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:38:31 -04:00

1909 lines
143 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// End-to-end tests of the browser MoQ client against joule-moq-relay on loopback, in the
// host's installed browser driven by the automation package of e2e/package.json, with a
// fake camera and microphone.
//
// node e2e/room.e2e.mjs --relay <joule-moq-relay> --wai <wai_live_web> --web <web dir>
// --corpus <live-conformance/compose/vectors.json> [--browser <executable>]
// [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,ageco,binding,cowatch,
// origin]
// [--runs N]
// [--between <s>] [--out <dir>] [--room <joule-moq-room>] [--base <origin>] [--seek on|off]
// [--wai-dir <wai>]
//
// Scenarios:
// compose live-conformance/compose run in the page: every layout, mix, descriptor
// and composition case through wai-live-web's wasm build, and wai-rs's own
// corpus check, in the browser;
// webtransport two participants and the room's clock over WebTransport: each publishes
// its camera and microphone and receives the other's, frames decode, every
// received object verifies against its receipt (and the relay's door checks
// each one), the composed canvas's tiles equal the host's layout for the
// same descriptor and set and show video, the mix reaches the AudioWorklet;
// a caption shows with its origin label; a third participant joins late and
// gets the earlier groups by FETCH, each verified;
// websocket the same, with the WebSocket binding forced on every page;
// goaway the relay is shut down with GOAWAY naming a second relay: every page
// moves there, closes its old session itself before the timeout, and media
// flows again;
// dvr a relay keeping a DVR window (WAI live-recording §8) with a cache too
// small for the window: a participant seeks back eight seconds into the
// other's tracks from its page's control; every object fetched from the
// window verifies against its receipt, the video plays from its first key
// frame in the page's own view, the page returns to live and live video goes
// on decoding; the relay's summaries show the objects came from the window;
// service a room service (joule-moq-room) keeps the room, as in production: each
// page makes its own key and enters from its buttons, the service grants and
// its clock authors the lifecycle, the relay (WebSocket only, behind a
// same-origin proxy as behind the production front) honours the service's
// grants by its authority's key. Two participants publish and see each
// other, every object verifies, the layout equals the host's, a machine and a
// typed human caption show their labels, a late joiner gets earlier groups, a
// participant seeks back into the other's tracks from the relay's DVR window
// with the dvr scenario's checks (the relay here keeps one; `--seek off`, or a
// deployment unless `--seek on`, skips the step and reports it skipped), a
// viewer watches, a grant with no proof or a proof by another key is refused
// the session, a publisher past the room's places is told it is full, and the
// room records a participant leaving;
// e2ee an end-to-end encrypted room (WAI confidentiality) kept by a room service:
// the page says so before anyone enters; each page checks the room's record,
// keys through the service's log (its KeyPackage, the clock's proposals, a
// GroupInfo, commits, Welcomes), seals every frame before it is signed and
// opens every object before it is decoded; media flows both ways and decodes,
// the page shows its epoch and roster, a subscriber outside the group (a
// viewer) receives exactly the sealed bytes and opens nothing, every change a
// relay could make to an object is refused with its reason, an object changed
// on the wire is refused bad_tag, a late joiner opens nothing sealed before
// its join, a member that stops publishing is removed and opens no later
// epoch while still receiving, an object it sealed before is refused
// sender_removed, and the relay's DVR store keeps nothing of the room.
// age a room that admits by age (WAI age-assurance §5.6), its service configured
// with the corpus's test issuers and rating the room 16, and a test issuer on
// loopback over the published test keys: each page answers the service's
// Privacy Pass challenges (RFC 9577) with a token it obtains by blind issuance
// (RFC 9578 §6) and enters at the token's threshold. A performer at 18 rates its
// own video 18 (§3.6); a viewer at 16 is served its audio, verified and decoded,
// and the relay refuses it the video (below_threshold, §5.5), which the page shows
// as withheld; a viewer at 18 is served both; a viewer whose token is of 13 is
// refused by the service (below_threshold); a token spent once is refused when
// presented again (token_replayed); the service's admission receipt (§7)
// verifies under the room's clock and counts each admission, every cell under 50
// suppressed.
// co a room with co-performance (WAI co-performance) kept by a room service, behind
// a relay that holds the floor and the audience's inputs: a lead, an AI
// participant (the page's scripted demo, synthetic media) and three audience
// members. The lead starts turns from its page's controls; the performers'
// pages show who holds the floor and send media only in their turns; one plays
// out of turn and is marked by the room's clock, and under a later schedule that
// refuses, its objects out of turn are held back by the relay; the AI
// participant's lines show as upcoming before they play, one kept and one not;
// an object late past its budget is marked; the audience votes and sends prompts
// from the page's input box, the relay withholding each input §7.3 refuses with
// its reason; each window is committed to and selected from by the clock, its
// selection and credits shown; the lead asks for the fidelity statement; and the
// room as a page holds it, with the clock's log of objects, verifies (accept) the
// same in the page and on the host, every out-of-turn and late object marked.
// ageco a room that admits by age and keeps co-performance: the room's clock proves
// the room's keys and the relay serves its session as the room's own service
// (age-assurance §5.5): it logs both performers' tracks, the lead's video rated
// 18 among them, and marks an object out of turn; a viewer at 16 is still
// refused that video.
// origin the endpoints a link may name: a link naming a relay on another origin is
// refused when the visitor joins, and nothing connects (no WebSocket, no
// request, no grant, no camera); a room service on another origin, written
// scheme-relative, behind the page's host as user information or as javascript:,
// is refused and Join stays off; a relay whose GOAWAY names another origin is
// refused, the session ends and the page says so. And in the service scenario,
// every request and WebSocket of every page stays on the page's own origin.
// binding the browser sink (WAI browser-sink): the page web/binding/ runs
// live-conformance/browser through the page's wasm and side table, decodes a
// wai.video.int_motion object on the wasm route after verifying it against
// its receipts and binds every frame (each frame's SHA-256 the published
// one), carries the binding through clone(), transfer to a worker, copyTo()
// and re-encode as §8 has it, binds the user agent's own decoder's frames
// through the side table, reports what the user agent keeps of an
// unregistered VideoFrame metadata member, and builds a capability report
// from the user agent's answers, putting no codec string outside the
// registry to it; on a host with a per-process energy counter, a measured
// figure per decode is reported beside powerEfficient. Then a room with
// `debug=binding`: every tile's frame is bound to an object its page
// verified, under that object's receipt, and the page shows it verified.
// cowatch a watch room (WAI co-watch) kept by a room service: three viewers, each
// page fetching the source and checking it by the content hash the room's
// policy names, entering the room's co-watch log by a proof of its key; one
// plays, one pauses, one seeks, each control applied by every page at its
// instant; a viewer joins late and is brought to the room's position; every
// page states what it presents twice a slot, and the room clock's skew
// statements measure them: in the last windows every viewer and every pair is
// in sync within the policy's bound, as each page shows; and the room, read
// back from the service, verifies by co_watch_verify.py, the corpus's second
// implementation, every skew statement recomputed.
//
// With `--base <origin>` the service scenario runs against a deployment (the page at
// <origin>/room/, its service and relay behind the same origin): nothing is started here,
// and only `--wai` is needed beside the browser.
//
// It prints one line per check and `room e2e: <passed> of <total> runs passed`, and exits
// non-zero when any check fails.
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { serve, serveSameOrigin, startRelay, roomKeys, startRoomService, makeFixture, hostFrame, hostCoVerify, browserArgs, memguardedBrowser, killStrays, tmpdir, until, sleep, RUN_ID, writeWav } from "./lib.mjs";
import { startIssuer, testKeys } from "./issuer.mjs";
import crypto from "node:crypto";
const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] && !all[i + 1].startsWith("--") ? all[i + 1] : "1"]] : a), []));
const need = (k) => { if (!args[k]) { console.error(`room e2e: --${k} is required`); process.exit(2); } return path.resolve(args[k]); };
const BASE = args.base ? args.base.replace(/\/$/, "") : null;
const WAI = need("wai");
const [RELAY, WEB, CORPUS] = BASE ? [null, null, null] : [need("relay"), need("web"), need("corpus")];
const ROOM = args.room ? path.resolve(args.room) : null;
const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway,dvr${ROOM ? ",service,e2ee,age,co,ageco,cowatch,origin" : ""},binding`)).split(",");
// The wai/ directory: the browser sink's corpus and the int_video frame digests it points to.
const WAI_DIR = args["wai-dir"] ? path.resolve(args["wai-dir"]) : (CORPUS ? path.resolve(path.dirname(CORPUS), "..", "..") : null);
const RUNS = Number(args.runs || 1);
// `--seek on|off`: the service scenario's seek back into the relay's DVR window. On by default
// on loopback, where the relay this starts keeps a window; off by default against a
// deployment (`--base`), whose relay may keep none, and then reported as skipped.
const SEEK = (args.seek || (BASE ? "off" : "on")) === "on";
if (args.seek && !["on", "off"].includes(args.seek)) { console.error("room e2e: --seek is on or off"); process.exit(2); }
const OUT = args.out ? path.resolve(args.out) : tmpdir("out");
fs.mkdirSync(OUT, { recursive: true });
const require = createRequire(path.join(process.env.PLAYWRIGHT_HOST || path.join(path.dirname(new URL(import.meta.url).pathname), "node_modules"), "x"));
const { chromium } = require("playwright");
const STEP = 60000; // the bound on any one wait
const results = [];
let failed = 0;
function check(name, ok, detail = "") {
results.push({ name, ok, detail });
if (!ok) failed++;
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
}
// A step not run, and why: listed apart, never counted as a check that passed.
const skipped = [];
function skip(name, why) {
skipped.push({ name, why });
console.log(`skip ${name} — ${why}`);
}
process.on("unhandledRejection", (e) => { console.error("unhandled:", e && e.stack || e); killStrays(); process.exit(1); });
async function launch(dir) {
const bin = args.browser ? path.resolve(args.browser) : null;
const opts = { headless: true, args: browserArgs() };
let guarded = false;
if (bin) {
const g = memguardedBrowser(bin, dir);
opts.executablePath = g.executable;
guarded = g.guarded;
}
const b = await chromium.launch(opts);
console.log(`browser: ${b.version()} (${bin || "the automation package's own"}${guarded ? ", under memguard" : ""})`);
return b;
}
async function page(browser, url, name, dir) {
const ctx = await browser.newContext({ permissions: ["camera", "microphone"], colorScheme: "dark" });
const p = await ctx.newPage();
const logs = [];
p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
p.on("pageerror", (e) => logs.push(`[pageerror] ${e.stack || e}`));
p.on("response", (r) => { if (r.status() >= 400) logs.push(`[http ${r.status()}] ${r.url()}`); });
// Every request and WebSocket the page makes, for the checks of where it connects.
p.requests = [];
p.sockets = [];
p.on("request", (r) => p.requests.push(r.url()));
p.on("websocket", (w) => p.sockets.push(w.url()));
await p.goto(url, { waitUntil: "domcontentloaded", timeout: STEP });
p.dump = () => fs.writeFileSync(path.join(dir, `${name}.console.log`), logs.join("\n"));
p.snap = () => p.evaluate(() => window.roomTest && window.roomTest.snapshot && window.roomTest.snapshot());
p.name = name;
return p;
}
// The requests and WebSockets of `p` that leave `origin`, whose relay is the WebSocket of the
// same host (blob: and data: URLs stay in the page).
function offOrigin(p, origin) {
const own = new URL(origin);
const wsOwn = `${own.protocol === "https:" ? "wss:" : "ws:"}//${own.host}/`;
return [...p.requests, ...p.sockets].filter((u) => !/^(blob|data):/.test(u) && !u.startsWith(`${own.origin}/`) && !u.startsWith(wsOwn));
}
async function waitPhase(p, phase) {
return until(`${p.name} ${phase}`, STEP, async () => {
const s = await p.snap();
if (s && s.errors && s.errors.length) throw new Error(`${p.name}: ${s.errors[0]}`);
return s && s.phase === phase && s;
}, 200);
}
// The clock announces a participant once its page publishes (live-rooms §7.2).
async function announce(clock, fx, who) {
const p = fx.participants.find((x) => x.name === who);
await clock.evaluate((d) => window.roomTest.clockEmit(d), { kind: "join", subject: p.principal, grant: p.grant_digest });
await clock.evaluate((d) => window.roomTest.clockEmit(d), { kind: "publish", subject: p.principal, media: 1, profile: 0, layers: 1 });
await clock.evaluate((d) => window.roomTest.clockEmit(d), { kind: "publish", subject: p.principal, media: 2, profile: 1, layers: 1 });
}
function roomUrl(base, relay, fxFile, extra) {
const u = new URL("/room/", base);
u.searchParams.set("fixture", "/fixture.json");
u.searchParams.set("wt", relay.wt);
u.searchParams.set("ws", relay.wsUrl);
u.searchParams.set("hashes", (relay.hashes || [relay.hash]).join(","));
for (const [k, v] of Object.entries(extra)) u.searchParams.set(k, v);
void fxFile;
return u.toString();
}
const principalOf = (fx, who) => fx.participants.find((x) => x.name === who).principal;
// --------------------------------------------------------------------------------- compose
async function scenarioCompose(run) {
const dir = path.join(OUT, `run${run}-compose`);
fs.mkdirSync(dir, { recursive: true });
const srv = await serve(WEB, { "/vectors/compose.json": CORPUS });
const browser = await launch(dir);
try {
const p = await page(browser, `${srv.url}/compose/`, "compose", dir);
const r = await until("the corpus in the page", STEP * 3, () => p.evaluate(() => window.composeTest), 500);
p.dump();
fs.writeFileSync(path.join(dir, "compose.json"), JSON.stringify(r, null, 1));
if (r.error) return check("compose: the corpus runs in the page", false, r.error);
const n = r.layouts + r.mixes + r.compositions + r.descriptors;
check("compose: every layout, mix, descriptor and composition case through the wasm build gives the corpus's outcome", r.failures.length === 0 && n > 0,
`${r.layouts} layouts, ${r.mixes} mixes, ${r.descriptors} descriptors, ${r.compositions} compositions, ${r.tiles} tiles; ${r.failures.length} differ${r.failures.length ? ": " + r.failures.slice(0, 5).join("; ") : ""}`);
check("compose: wai-rs's own corpus check holds in the browser", r.corpus_verify.length === 0, r.corpus_verify.slice(0, 3).join("; "));
} finally {
await browser.close();
await srv.close();
}
}
// ------------------------------------------------------------------------------------ room
function mediaChecks(label, snap, from, { minVideo, minAudio }) {
const s = snap.subs[from];
if (!s) return check(`${label}: subscribed to ${from.slice(0, 8)}`, false, "no subscription");
const v = s.video, a = s.audio;
check(`${label}: video frames from the other participant decode`, v.decoded >= minVideo, `${v.decoded} decoded, ${v.received} received, codec ${snap.codec.video}`);
// An object still waiting for its receipts when the snapshot is taken is in flight, not
// refused; one that waited past its bound is counted as refused.
check(`${label}: every received video object verifies against its receipt`, v.refused === 0 && v.receipts_refused === 0 && v.verified >= minVideo && v.received - v.verified <= 10,
`${v.verified} verified of ${v.received}; ${v.receipts} receipt groups (${JSON.stringify(v.chain)}); refused ${v.refused}, receipts refused ${v.receipts_refused}; receipts fetched for ${v.receipt_fetches || 0} group(s), ${v.receipts_recovered || 0} recovered`);
check(`${label}: audio decodes and verifies`, a.decoded >= minAudio && a.refused === 0 && a.receipts_refused === 0 && a.verified >= minAudio, `${a.decoded} decoded, ${a.verified} verified of ${a.received}; ${a.receipts} receipt groups; refused ${a.refused}; receipts fetched for ${a.receipt_fetches || 0} group(s), ${a.receipts_recovered || 0} recovered`);
check(`${label}: dropped objects whose receipts arrived are accounted for by inclusion`, v.dropped_unaccounted === 0 && a.dropped_unaccounted === 0,
`video ${v.dropped_accounted} accounted, ${v.reset} streams reset; audio ${a.dropped_accounted} accounted, ${a.reset} reset`);
}
async function compareLayout(label, p, fx, dir, expectTiles) {
// A frame of the page, at its own last re-layout, and the same room as it holds it.
const snap = await p.snap();
const f = snap.frame;
const roomJson = await p.evaluate(() => window.roomTest.roomJson());
const compose = fx ? fx.compose : await p.evaluate(() => window.roomTest.compose());
const host = hostFrame(WAI, roomJson, compose, f.t, dir);
const hf = host.frames && host.frames[0];
const same = hf && JSON.stringify({ o: hf.order, t: hf.tiles, v: hf.voices, s: hf.set_digest }) === JSON.stringify({ o: f.order, t: f.tiles, v: f.voices, s: f.set_digest });
check(`${label}: the page's layout at t=${f.t} equals the host's for the same descriptor and set`, !!same && f.tiles.length === expectTiles,
`${f.tiles.length} tiles; set ${f.set_digest.slice(0, 16)}; host ${hf ? hf.set_digest.slice(0, 16) : JSON.stringify(host).slice(0, 200)}`);
// The rectangles drawn on the canvas are the layout's.
const drawnOk = f.tiles.every((t) => { const d = snap.drawn[t.principal]; return d && JSON.stringify([d.tile, d.src, d.dst]) === JSON.stringify([t.tile, t.src, t.dst]); });
check(`${label}: every tile was drawn into the layout's rectangles`, drawnOk, f.tiles.map((t) => `${t.principal.slice(0, 6)} ${JSON.stringify(t.dst)}`).join(", "));
const bg = [0x10, 0x12, 0x18];
const shown = snap.samples.filter((s) => s.principal !== "" && JSON.stringify(s.rgb) !== JSON.stringify(bg));
check(`${label}: each tile shows video, not the background`, shown.length === f.tiles.length, snap.samples.map((s) => `${s.principal.slice(0, 6)}:${s.rgb}`).join(" "));
return snap;
}
async function scenarioRoom(run, transport) {
const label = transport;
const dir = path.join(OUT, `run${run}-${transport}`);
fs.mkdirSync(dir, { recursive: true });
const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob", "carol"], dir);
const relay = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: path.join(dir, "summaries.jsonl") }, dir, "relay");
const srv = await serve(WEB, { "/fixture.json": file });
const browser = await launch(dir);
const force = transport === "websocket" ? { transport: "websocket" } : {};
const pages = [];
try {
const clock = await page(browser, roomUrl(srv.url, relay, file, { role: "clock", ...force }), "clock", dir);
pages.push(clock);
await waitPhase(clock, "ready");
const [A, B] = [principalOf(fx, "alice"), principalOf(fx, "bob")];
const alice = await page(browser, roomUrl(srv.url, relay, file, { who: "alice", ...force }), "alice", dir);
pages.push(alice);
await waitPhase(alice, "publishing");
await announce(clock, fx, "alice");
// Over the binding, bob is not forced onto it: its page is given a certificate hash
// that is not the relay's, so its WebTransport session does not open, and the page
// falls back to the binding by itself.
const bobOpts = transport === "websocket" ? { who: "bob", hashes: "00".repeat(32) } : { who: "bob" };
const bob = await page(browser, roomUrl(srv.url, relay, file, bobOpts), "bob", dir);
pages.push(bob);
await waitPhase(bob, "publishing");
await announce(clock, fx, "bob");
const s0 = await alice.snap();
check(`${label}: the session runs over ${transport}`, s0.transport.kind === transport, `${s0.transport.kind}, protocol "${s0.transport.protocol}"`);
if (transport === "websocket") {
const sb = await bob.snap();
const fell = await bob.evaluate(() => window.roomTest.logLines.find((l) => l.includes("falling back to the WebSocket binding")) || "");
check(`${label}: a page whose WebTransport session does not open falls back to the binding by itself`, sb.transport.kind === "websocket" && !!fell, fell.replace(/^[0-9.]+ /, "").slice(0, 200));
}
// Both receive each other.
for (const [p, other] of [[alice, B], [bob, A]]) {
await until(`${p.name} decodes video from the other`, STEP * 2, async () => {
const s = await p.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const t = s.subs[other];
return t && t.video.decoded >= 30 && t.audio.decoded >= 50 && s.mixed.voiced_blocks > 0;
}, 500);
}
for (const [p, other] of [[alice, B], [bob, A]]) mediaChecks(`${label} ${p.name}`, await p.snap(), other, { minVideo: 30, minAudio: 50 });
const sa = await alice.snap();
check(`${label}: the mix reaches the AudioWorklet`, sa.mixed.voiced_blocks > 0 && sa.mixed.peak > 0 && sa.worklet.played > 0, `${sa.mixed.voiced_blocks} voiced blocks, peak ${sa.mixed.peak}, ${sa.worklet.played} frames played, context ${sa.audioState}`);
await compareLayout(`${label} alice`, alice, fx, dir, 2);
// Captions, with their origin label.
const model = "9d".repeat(32);
await alice.evaluate((m) => window.roomTest.sendCaption({ text: "the machine heard this", origin: 2, model: m, lang: "en" }), model);
const cap = await until(`${label} bob shows alice's caption`, STEP, async () => {
const s = await bob.snap();
const c = s.captions.find((x) => x.who === "alice" && x.text === "the machine heard this");
const dom = await bob.evaluate(() => ({ label: document.querySelector("#caption .origin")?.textContent, text: document.querySelector("#caption .text")?.textContent }));
return c && dom.text === c.text && { c, dom };
});
check(`${label}: a machine caption shows with its origin label`, cap.c.origin === "machine" && cap.c.model === model && /Machine-generated/.test(cap.dom.label), `${cap.dom.label}: "${cap.dom.text}"`);
await bob.evaluate(() => window.roomTest.sendCaption({ text: "typed by a person", origin: 1, lang: "en" }));
const cap2 = await until(`${label} alice shows bob's caption`, STEP, async () => {
const dom = await alice.evaluate(() => ({ label: document.querySelector("#caption .origin")?.textContent, text: document.querySelector("#caption .text")?.textContent }));
return dom.text === "typed by a person" && dom;
});
check(`${label}: a human caption shows with its origin label`, cap2.label === "Human captioner", cap2.label);
const tl = (await bob.snap()).timeline;
const kinds = [...new Set(tl.map((e) => e.kind))];
check(`${label}: the events timeline shows the lifecycle and the captions`, ["join", "publish", "caption"].every((k) => kinds.includes(k)), `${tl.length} events: ${kinds.join(", ")}`);
// A late joiner: earlier groups by FETCH.
const carol = await page(browser, roomUrl(srv.url, relay, file, { who: "carol", fetch: "1", ...force }), "carol", dir);
pages.push(carol);
await waitPhase(carol, "publishing");
await announce(clock, fx, "carol");
for (const other of [A, B]) {
const t = await until(`carol fetches the earlier groups of ${other.slice(0, 8)}`, STEP * 2, async () => {
const s = await carol.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const v = s.subs[other] && s.subs[other].video;
return v && v.fetched > 0 && v.fetched_verified === v.fetched && v.decoded > 0 && v.fetched_decoded >= Math.min(10, v.fetched) && v;
}, 500);
check(`${label}: a late joiner gets earlier groups by FETCH, each verified`, t.first_fetched_group === 0 && t.fetched_verified === t.fetched && t.fetched >= 15,
`${t.fetched} fetched (groups ${t.first_fetched_group}..${t.last_fetched_group}), ${t.fetched_verified} verified, ${t.fetched_decoded} decoded; live from group ${t.first_live_group}`);
}
// Everyone re-lays out for three.
await until(`${label} alice re-lays out for three`, STEP, async () => { const s = await alice.snap(); return s.frame && s.frame.tiles.length === 3 && Object.keys(s.drawn).length === 3 && s.samples.every((x) => JSON.stringify(x.rgb) !== "[16,18,24]"); }, 500);
const sl = await compareLayout(`${label} alice after the late join`, alice, fx, dir, 3);
check(`${label}: re-layout on lifecycle events`, sl.layouts.length >= 2, sl.layouts.map((l) => `${l.tiles.length} tile(s) at ${(l.t / 1e6).toFixed(2)}s`).join(", "));
// The late joiner's fill from group 0 (jwp-receipts §3 step 3): its chains break
// nowhere. Where its live receipts anchored a chain first, the fetched groups, group 0
// among them, are admitted back from the anchor as history; where the fetch's receipts
// came first, group 0 starts the chain and the rest link to it.
const sc = await carol.snap();
const cv = Object.values(sc.verifiers).flatMap((tracks) => Object.values(tracks));
const sum = (f) => cv.reduce((n, v) => n + (f(v) || 0), 0);
const breaks = sum((v) => v.refusals["chain:chain_break"]);
const how = { anchor: sum((v) => v.admissions.anchor), history: sum((v) => v.admissions.history), linked: sum((v) => v.admissions.linked) };
check(`${label}: the late joiner's chains, filled from group 0, break nowhere`, breaks === 0 && how.history + how.linked > 0,
`${how.anchor} anchored, ${how.history} admitted as history, ${how.linked} linked; ${breaks} chain_break`);
// The receipts objects as received (jwp-receipts §2.7.3), and the same receipts in the
// JSON form the receipts track carried before.
const sv = (await alice.snap()).verifiers[B] || {};
const sizes = Object.entries(sv).map(([m, v]) => `${m}: ${v.receipts_bytes.min}-${v.receipts_bytes.max} B over ${v.receipts_bytes.objects} objects (JSON ${v.receipts_json_bytes.min}-${v.receipts_json_bytes.max} B)`);
const smaller = Object.values(sv).every((v) => v.receipts_bytes.objects > 0 && v.receipts_bytes.max < v.receipts_json_bytes.min);
check(`${label}: receipts objects ride in the binary form`, smaller && Object.keys(sv).length === 2, sizes.join("; "));
fs.writeFileSync(path.join(dir, "receipts-sizes.json"), JSON.stringify(sv, null, 1));
// A grant presented as a bare token, with no proof of its key: the relay refuses the
// session (live-rooms §10.5.2), UNAUTHORIZED. The page reads the code (2) and the reason
// over either transport: over WebTransport the relay closes the WebTransport session
// with them (WT_CLOSE_SESSION, moq-transport-22 §6.6), as over the WebSocket binding.
const bare = await alice.evaluate(() => window.roomTest.tryBareGrant());
const refusals = relay.lines.filter((l) => l.includes("without a proof that passes"));
const coded = !!bare.closed && bare.closed.code === 2 && /pop_missing/.test(bare.closed.reason);
check(`${label}: a grant presented without its proof is refused the session`, coded && refusals.some((l) => l.includes("pop_missing")),
`${JSON.stringify(bare).slice(0, 200)}; relay: ${(refusals[0] || "").replace(/^.*INFO /, "").slice(0, 200)}`);
check(`${label}: the relay refused that session and no other`, refusals.length === 1, `${refusals.length} refused`);
const refusedAtDoor = relay.lines.filter((l) => l.includes("refused at the door")).length;
check(`${label}: the relay's door verified every media object and refused none`, refusedAtDoor === 0, `${refusedAtDoor} refused`);
for (const p of pages) {
const s = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1));
check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | "));
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
await browser.close().catch(() => {});
await srv.close();
const e = await relay.stop();
console.log(`relay stopped (${JSON.stringify(e)}); log ${relay.log}`);
killStrays();
}
}
// ---------------------------------------------------------------------------------- goaway
async function scenarioGoaway(run) {
const label = "goaway";
const dir = path.join(OUT, `run${run}-goaway`);
fs.mkdirSync(dir, { recursive: true });
const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob"], dir);
const base = { auth: true, grants: fx.relay_grants, door: fx.door_rules };
const next = await startRelay(RELAY, { ...base, summary_out: path.join(dir, "summaries-b.jsonl") }, dir, "relay-b");
const first = await startRelay(RELAY, { ...base, summary_out: path.join(dir, "summaries-a.jsonl"), goaway_uri: next.wt, goaway_timeout_ms: 10000 }, dir, "relay-a");
first.hashes = [first.hash, next.hash];
const srv = await serve(WEB, { "/fixture.json": file });
const browser = await launch(dir);
const pages = [];
try {
const clock = await page(browser, roomUrl(srv.url, first, file, { role: "clock" }), "clock", dir);
pages.push(clock);
await waitPhase(clock, "ready");
const [A, B] = [principalOf(fx, "alice"), principalOf(fx, "bob")];
const alice = await page(browser, roomUrl(srv.url, first, file, { who: "alice" }), "alice", dir);
pages.push(alice);
await waitPhase(alice, "publishing");
await announce(clock, fx, "alice");
const bob = await page(browser, roomUrl(srv.url, first, file, { who: "bob" }), "bob", dir);
pages.push(bob);
await waitPhase(bob, "publishing");
await announce(clock, fx, "bob");
for (const [p, other] of [[alice, B], [bob, A]]) {
await until(`${p.name} receives the other before GOAWAY`, STEP * 2, async () => { const s = await p.snap(); return s.subs[other] && s.subs[other].video.decoded >= 15; }, 500);
}
const before = { alice: (await alice.snap()).subs[B].video.verified, bob: (await bob.snap()).subs[A].video.verified };
// Shut the first relay down: GOAWAY to every session, naming the second.
first.proc.kill("SIGINT");
for (const p of [alice, bob, clock]) {
const g = await until(`${p.name} moves after GOAWAY`, STEP, async () => { const s = await p.snap(); return s.goaways.length && s.goaways[0].moved && s.goaways[0]; }, 250);
check(`${label}: ${p.name} reads GOAWAY with the new session URI and moves`, g.uri === next.wt, `URI ${g.uri}, timeout ${g.timeout_ms} ms, moved over ${g.moved.transport} in ${Math.round(g.moved.at - g.at)} ms`);
}
for (const [p, other, k] of [[alice, B, "alice"], [bob, A, "bob"]]) {
const v = await until(`${p.name} receives the other through the second relay`, STEP * 2, async () => { const s = await p.snap(); const t = s.subs[other]; return t && t.video.verified >= before[k] + 15 && t.video.refused === 0 && t.video; }, 500);
check(`${label}: ${p.name} receives and verifies media again after moving`, true, `${v.verified - before[k]} more verified video objects, ${v.refused} refused`);
}
const e = await first.stop(null, 30000);
const stopped = first.lines.find((l) => /stopped; \d+ session\(s\) closed at the GOAWAY timeout/.test(l)) || "";
const n = Number((stopped.match(/stopped; (\d+)/) || [])[1]);
check(`${label}: every page closed its old session itself before the GOAWAY timeout`, n === 0, `${stopped.trim()} (${JSON.stringify(e)})`);
for (const p of pages) {
const s = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1));
check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | "));
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
await browser.close().catch(() => {});
await srv.close();
if (first.proc.exitCode == null) await first.stop("SIGKILL");
await next.stop();
killStrays();
}
}
// --------------------------------------------------------------------------------- service
// Where the page's sections are, as the browser parsed them: the Log, the binding table and the
// co-watch section are never inside the co-performance section, which a room without
// co-performance hides; and the Log shows.
async function markup(p) {
return p.evaluate(() => {
const co = document.getElementById("co");
const inside = (id) => { const e = document.getElementById(id); return !!(co && e && co.contains(e)); };
const shows = (id) => { const e = document.getElementById(id); return !!e && (e.checkVisibility ? e.checkVisibility() : e.getClientRects().length > 0); };
return { co_hidden: !!(co && co.hidden), in_co: ["log", "binding-debug", "cowatch"].filter(inside), log_shows: shows("log"), cowatch_shows: shows("cowatch") };
});
}
async function scenarioService(run) {
const label = BASE ? "production" : "service";
const dir = path.join(OUT, `run${run}-${label}`);
fs.mkdirSync(dir, { recursive: true });
let relay = null, svc = null, srv = null, base;
if (BASE) {
base = `${BASE}/room/`;
} else {
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the service scenario");
const k = roomKeys(ROOM, dir);
const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 }));
// A DVR window over every track and a cache far smaller than the seconds sought back, as
// the dvr scenario's relay keeps, so a page seeks back, out of the window, under the seek
// power the service's grants carry (live-rooms §10.6).
relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 }, cache_bytes: 128 * 1024, dvr: { path: path.join(dir, "dvr-store"), max_age_s: 900, max_bytes: 256 * 1024 * 1024 } }, dir, "relay");
svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 } }, dir);
srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws });
base = `${srv.url}/room/?service=/room-api`;
}
const url = (extra = {}) => { const u = new URL(base); for (const [k, v] of Object.entries(extra)) u.searchParams.set(k, v); return u.toString(); };
const browser = await launch(dir);
const pages = [];
const enter = async (name, button, extra, phase) => {
const p = await page(browser, url(extra), name, dir);
pages.push(p);
await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await p.click(button);
const s = await waitPhase(p, phase);
return { p, principal: s.me.principal, room: s.room };
};
try {
const A = await enter("alice", "#join", {}, "publishing");
const m = await markup(A.p);
check(`${label}: in a room without co-performance, the Log, the binding table and the co-watch section are not inside the co-performance section, and the Log shows`, m.co_hidden && m.in_co.length === 0 && m.log_shows, JSON.stringify(m));
const B = await enter("bob", "#join", {}, "publishing");
const [alice, bob] = [A.p, B.p];
check(`${label}: both participants are in one room`, A.room === B.room, `${A.room.slice(0, 16)} / ${B.room.slice(0, 16)}`);
const s0 = await alice.snap();
check(`${label}: the session runs over the WebSocket binding, with a grant the service issued for the page's own key`, s0.transport.kind === "websocket" && s0.grant.role === "publisher" && /^[0-9a-f]{64}$/.test(s0.grant.digest), `${s0.transport.kind}, grant ${s0.grant.digest.slice(0, 16)}, expires ${new Date(s0.grant.exp * 1000).toISOString()}`);
for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) {
await until(`${p.name} decodes video from the other`, STEP * 2, async () => {
const s = await p.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const t = s.subs[other];
return t && t.video.decoded >= 30 && t.audio.decoded >= 50 && s.mixed.voiced_blocks > 0;
}, 500);
}
for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) mediaChecks(`${label} ${p.name}`, await p.snap(), other, { minVideo: 30, minAudio: 50 });
const sa = await alice.snap();
check(`${label}: the mix reaches the AudioWorklet`, sa.mixed.voiced_blocks > 0 && sa.worklet.played > 0, `${sa.mixed.voiced_blocks} voiced blocks, ${sa.worklet.played} frames played`);
const present = (s) => (s.roomState && s.roomState.present ? s.roomState.present.length : 0);
// Laid out, and every tile showing video: a tile's first frame follows its layout.
const shown = (s) => s.frame && s.frame.tiles.length === present(s) && Object.keys(s.drawn || {}).length === s.frame.tiles.length && s.samples.every((x) => x.principal === "" || JSON.stringify(x.rgb) !== "[16,18,24]");
await until(`${label} alice lays out the room`, STEP, async () => { const s = await alice.snap(); return present(s) >= 2 && shown(s); }, 500);
const sl = await alice.snap();
await compareLayout(`${label} alice`, alice, null, dir, present(sl));
const tl = (await bob.snap()).timeline;
const byRoom = tl.filter((e) => e.who === "room").map((e) => e.kind);
check(`${label}: the room's clock, not a page, authors the lifecycle`, ["join", "publish"].every((k) => byRoom.includes(k)) && !tl.some((e) => ["join", "publish", "leave"].includes(e.kind) && e.who !== "room"), `${byRoom.length} lifecycle events by the room's clock`);
// Captions: a machine caption with its model, and one typed by a person in the page.
const model = "9d".repeat(32);
await alice.evaluate((m) => window.roomTest.sendCaption({ text: "the machine heard this", origin: 2, model: m, lang: "en" }), model);
const cap = await until(`${label} bob shows alice's caption`, STEP, async () => {
const dom = await bob.evaluate(() => ({ label: document.querySelector("#caption .origin")?.textContent, text: document.querySelector("#caption .text")?.textContent }));
return dom.text === "the machine heard this" && dom;
});
check(`${label}: a machine caption shows with its origin label`, /Machine-generated/.test(cap.label), `${cap.label}: "${cap.text}"`);
await bob.fill("#caption-text", "typed by a person");
await bob.press("#caption-text", "Enter");
const cap2 = await until(`${label} alice shows bob's typed caption`, STEP, async () => {
const dom = await alice.evaluate(() => ({ label: document.querySelector("#caption .origin")?.textContent, text: document.querySelector("#caption .text")?.textContent }));
return dom.text === "typed by a person" && dom;
});
check(`${label}: a caption typed in the page shows as a human caption`, cap2.label === "Human captioner", cap2.label);
// A late joiner gets the earlier groups of both by FETCH.
const C = await enter("carol", "#join", { fetch: "1" }, "publishing");
for (const other of [A.principal, B.principal]) {
const t = await until(`carol fetches the earlier groups of ${other.slice(0, 8)}`, STEP * 2, async () => {
const s = await C.p.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const v = s.subs[other] && s.subs[other].video;
return v && v.fetched > 0 && v.fetched_verified === v.fetched && v.decoded > 0 && v.fetched_decoded >= Math.min(10, v.fetched) && v;
}, 500);
check(`${label}: a late joiner gets earlier groups by FETCH, each verified`, t.first_fetched_group === 0 && t.fetched_verified === t.fetched && t.fetched >= 15,
`${t.fetched} fetched (groups ${t.first_fetched_group}..${t.last_fetched_group}), ${t.fetched_verified} verified, ${t.fetched_decoded} decoded`);
}
await until(`${label} alice re-lays out for the late joiner`, STEP, async () => { const s = await alice.snap(); return s.frame && s.frame.tiles.some((x) => x.principal === C.principal) && shown(s); }, 500);
const s3 = await alice.snap();
await compareLayout(`${label} alice after the late join`, alice, null, dir, present(s3));
// Seek back: bob into alice's tracks, from his page's control, out of the relay's window.
// Against a deployment whose relay keeps no window, or with `--seek off`, the step is
// skipped and reported so, never counted as passed.
if (SEEK) await seekBackChecks(label, bob, A.principal, dir);
else skip(`${label}: seek back into the relay's window`, BASE ? "a deployment: its relay's window is not known here (--seek on runs the step)" : "--seek off");
// A viewer watches: every publisher, verified, with no camera of its own.
const V = await enter("viewer", "#watch", {}, "watching");
await until(`${label} the viewer receives every publisher`, STEP * 2, async () => {
const s = await V.p.snap();
if (s.errors.length) throw new Error(s.errors[0]);
return [A.principal, B.principal, C.principal].every((P) => s.subs[P] && s.subs[P].video.verified >= 10 && s.subs[P].video.decoded >= 10);
}, 500);
const sv = await V.p.snap();
check(`${label}: a viewer receives and verifies every publisher, and publishes nothing`, sv.published.video === 0 && sv.published.audio === 0 && [A.principal, B.principal, C.principal].every((P) => sv.subs[P].video.refused === 0),
[A.principal, B.principal, C.principal].map((P) => `${P.slice(0, 6)} ${sv.subs[P].video.verified} verified`).join(", "));
// Proof of possession: the grant with no proof, and with a proof by another key.
const bare = await alice.evaluate(() => window.roomTest.tryBareGrant());
check(`${label}: a grant presented without its proof is refused the session`, !!bare.closed && bare.closed.code === 2 && /pop_missing/.test(bare.closed.reason), JSON.stringify(bare.closed));
const wrong = await alice.evaluate(() => window.roomTest.tryWrongKey());
check(`${label}: a grant presented with a proof by another key is refused the session`, !!wrong.closed && wrong.closed.code === 2 && /pop_wrong_key/.test(wrong.closed.reason), JSON.stringify(wrong.closed));
if (!BASE) {
// Past the room's places (three here), a publisher is told the room is full.
const d = await page(browser, url(), "dave", dir);
pages.push(d);
await until("dave's page ready", STEP, () => d.evaluate(() => window.roomTest && window.roomTest.state.phase === "idle"), 200);
await d.click("#join");
const full = await until("dave told the room is full", STEP, async () => { const s = await d.snap(); return s.phase === "full" && s; }, 200);
const msg = await d.evaluate(() => document.getElementById("status").textContent);
check(`${label}: a publisher past the room's places is told it is full, and may watch`, full.phase === "full" && /publishers/.test(msg) && await d.evaluate(() => !document.getElementById("watch").disabled), msg);
}
// Alice leaves: the room records it, and everyone re-lays out.
await alice.click("#leave");
const left = await until(`${label} bob sees alice leave`, STEP, async () => {
const s = await bob.snap();
return s.timeline.some((e) => e.kind === "leave" && e.who === "room") && s.frame && !s.frame.tiles.some((x) => x.principal === A.principal) && s;
}, 500);
check(`${label}: a participant leaving is recorded by the room, and the others re-lay out`, true, `${left.frame.tiles.length} tile(s) after the leave`);
// The production page is served with `connect-src 'self' wss://<its host> blob:`: every page
// here connects to its own origin alone.
const pageOrigin = new URL(base).origin;
const away = pages.flatMap((x) => offOrigin(x, pageOrigin));
const nReq = pages.reduce((n, x) => n + x.requests.length, 0), nWs = pages.reduce((n, x) => n + x.sockets.length, 0);
check(`${label}: every request and WebSocket of every page stays on the page's own origin`, away.length === 0 && nWs > 0, away.length ? away.slice(0, 3).join(" ") : `${nReq} requests, ${nWs} WebSockets, all on ${pageOrigin}`);
for (const p of pages) {
const s = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1));
check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | "));
}
if (relay) {
const refusedAtDoor = relay.lines.filter((l) => l.includes("refused at the door")).length;
check(`${label}: the relay's door verified every media object and refused none`, refusedAtDoor === 0, `${refusedAtDoor} refused`);
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
// Every page leaves, so the room it used is left as it was found. Each step is bounded.
const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]);
for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave()));
await bounded(browser.close());
if (srv) await bounded(srv.close());
if (svc) await bounded(svc.stop());
if (relay) await bounded(relay.stop());
killStrays();
}
if (relay && SEEK) {
const w = servedFromWindow(path.join(dir, "summaries.jsonl"));
check(`${label}: the relay served the seek back from its DVR window`, w.objects > 0, `${w.objects} objects from the window over ${w.lines} sessions`);
}
}
// ------------------------------------------------------------------------------------- dvr
// Seek back from a page's own control into `principal`'s tracks, as kept by a relay's DVR
// window (WAI live-recording §8, live-rooms §10.6): the seconds back, then Seek back; the
// window's video plays from its first key frame in a view of its own at the pace it was
// captured, every object verified against its receipt; then the page is back to live and live
// video goes on decoding. The dvr scenario (a fixture's room) and the service scenario (a room
// service's, the seek power in its grants) hold a seek back to these same checks.
const SEEK_SECONDS = 8;
async function seekBackChecks(label, p, principal, dir) {
const SECONDS = SEEK_SECONDS;
// More than the seconds sought back of the other's video and audio, live at this page.
const fps = 15;
await until(`${label}: ${p.name} has ${SECONDS + 4} s of the other live`, STEP * 2, async () => {
const s = await p.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const t = s.subs[principal];
return t && t.video.decoded > 0 && t.video.last_live_group != null && t.video.last_live_group - t.video.first_live_group >= (SECONDS + 4) * fps && t.audio.verified > 0;
}, 500);
// The control: seconds back, then Seek back.
await until(`${label}: the Seek back control is enabled`, STEP, () => p.evaluate(() => !document.getElementById("seek").disabled), 250);
await p.fill("#seek-s", String(SECONDS));
await p.click("#seek");
// A FETCH from the window under a loaded host can take a while to start playing, so this
// wait has the bound of the other heavy ones; and while it waits it states the page's
// window mode, view, status and last run, so a timeout says where the seek back stood.
const playing = await until(`${label}: ${p.name} plays from the window`, STEP * 2, async () => {
const s = await p.snap();
const [hidden, status] = await p.evaluate(() => [document.getElementById("dvr-canvas").hidden, document.getElementById("dvr-status").textContent]);
if (s.dvr.mode === "playing" && !hidden) return s.dvr;
const run = s.dvr.runs && s.dvr.runs[s.dvr.runs.length - 1];
throw new Error(`mode ${s.dvr.mode}, view ${hidden ? "hidden" : "shown"}, status "${(status || "").slice(0, 160)}", last run ${run ? JSON.stringify({ video: run.video, audio: run.audio, playing_at: run.playing_at, live_at: run.live_at }).slice(0, 300) : "none"}`);
}, 100);
check(`${label}: the page shows the window's video in a view of its own while it plays`, playing.runs.length === 1, (await p.evaluate(() => document.getElementById("dvr-status").textContent)).slice(0, 200));
const done = await until(`${label}: ${p.name} returns to live`, STEP * 2, async () => {
const s = await p.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const r = s.dvr.runs[0];
return r && r.live_at != null && s.dvr.mode === "live" && s;
}, 250);
const r = done.dvr.runs[0];
fs.writeFileSync(path.join(dir, "seek-back.json"), JSON.stringify(r, null, 1));
const v = r.video, a = r.audio;
check(`${label}: every fetched video object verifies against its receipt`, v.fetched > 0 && v.verified === v.fetched && v.refused === 0 && v.receipts_refused === 0,
`groups ${v.from}..${v.to}: ${v.verified} of ${v.fetched} verified, ${v.refused} refused, ${v.receipts_refused} receipts refused`);
check(`${label}: every fetched audio object verifies against its receipt`, a.fetched > 0 && a.verified === a.fetched && a.refused === 0 && a.receipts_refused === 0,
`groups ${a.from}..${a.to}: ${a.verified} of ${a.fetched} verified, ${a.refused} refused`);
check(`${label}: the window holds the whole span, nothing unknown`, v.fetched === v.to - v.from + 1 && a.fetched === a.to - a.from + 1 && v.unknown + a.unknown === 0,
`video ${v.fetched} of ${v.to - v.from + 1} groups, audio ${a.fetched} of ${a.to - a.from + 1}; ${v.unknown + a.unknown} unknown`);
const playedMs = r.live_at - r.playing_at;
check(`${label}: the video plays from its first key frame at the pace it was captured`, v.decoded > 0 && v.played >= (SECONDS - 2) * fps * 0.5 && playedMs >= (SECONDS - 2.5) * 1000 && a.decoded > 0,
`${v.played} frames shown, ${v.decoded} decoded, over ${(playedMs / 1000).toFixed(1)} s; ${a.decoded} audio frames decoded`);
check(`${label}: the page is back to live, its view of the window hidden`, done.dvr.mode === "live" && (await p.evaluate(() => document.getElementById("dvr-canvas").hidden)), done.dvr.mode);
const d0 = done.subs[principal].video.decoded;
const after = await until(`${label}: live video goes on decoding after the seek back`, STEP, async () => {
const s = await p.snap();
return s.subs[principal].video.decoded >= d0 + 2 * fps && s.subs[principal].video;
}, 500);
check(`${label}: live video goes on decoding after the seek back`, after.refused === 0, `${after.decoded} decoded (${d0} at the return), ${after.verified} verified`);
return r;
}
// The objects a relay's sessions were served from its DVR window, from its signed summaries.
function servedFromWindow(summaries) {
const lines = fs.existsSync(summaries) ? fs.readFileSync(summaries, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
return { lines: lines.length, objects: lines.reduce((n, l) => n + Object.values(l.summary.tally.subscriber.tracks || {}).reduce((m, t) => m + (t.dvr_objects || 0), 0), 0) };
}
async function scenarioDvr(run) {
const label = "dvr";
const dir = path.join(OUT, `run${run}-dvr`);
fs.mkdirSync(dir, { recursive: true });
const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob"], dir);
const summaries = path.join(dir, "summaries.jsonl");
// A cache far smaller than the seconds sought back: what is older comes from the window.
const relay = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: summaries, cache_bytes: 128 * 1024, dvr: { path: path.join(dir, "dvr-store"), max_age_s: 900, max_bytes: 256 * 1024 * 1024 } }, dir, "relay");
const srv = await serve(WEB, { "/fixture.json": file });
const browser = await launch(dir);
const force = { transport: "websocket" };
const pages = [];
try {
check(`${label}: the relay opened its DVR store`, relay.lines.some((l) => l.includes("the DVR store is open")), (relay.lines.find((l) => l.includes("DVR")) || "").replace(/^.*INFO /, "").slice(0, 160));
const clock = await page(browser, roomUrl(srv.url, relay, file, { role: "clock", ...force }), "clock", dir);
pages.push(clock);
await waitPhase(clock, "ready");
const A = principalOf(fx, "alice");
const alice = await page(browser, roomUrl(srv.url, relay, file, { who: "alice", ...force }), "alice", dir);
pages.push(alice);
await waitPhase(alice, "publishing");
await announce(clock, fx, "alice");
const bob = await page(browser, roomUrl(srv.url, relay, file, { who: "bob", ...force }), "bob", dir);
pages.push(bob);
await waitPhase(bob, "publishing");
await announce(clock, fx, "bob");
await seekBackChecks(label, bob, A, dir);
for (const p of pages) {
const s = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1));
check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | "));
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
await browser.close().catch(() => {});
await srv.close();
const e = await relay.stop();
console.log(`relay stopped (${JSON.stringify(e)}); log ${relay.log}`);
killStrays();
}
// The relay's signed summaries: the objects the seek back fetched came from its window.
const w = servedFromWindow(summaries);
check(`${label}: the relay served the seek back from its DVR window`, w.objects > 0, `${w.objects} objects from the window over ${w.lines} sessions`);
}
// ------------------------------------------------------------------------------------ e2ee
// The length a sealed video object has (confidentiality §4.4, §4.8): the payload's varint
// length, the payload, the Encrypted Properties List with WAI_INNER_DIGEST (2 + 1 + 2 + 1 +
// 32 bytes), and the 16-byte tag.
const vlen = (n) => (n < 64 ? 1 : n < 16384 ? 2 : n < 2 ** 30 ? 4 : 8);
const sealedLen = (n) => vlen(n) + n + 38 + 16;
const WAI1 = Buffer.from("WAI1").toString("hex");
const sum = (o) => Object.values(o || {}).reduce((a, b) => a + b, 0);
async function scenarioE2ee(run) {
const label = "e2ee";
const dir = path.join(OUT, `run${run}-e2ee`);
fs.mkdirSync(dir, { recursive: true });
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the e2ee scenario");
const k = roomKeys(ROOM, dir);
const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 }));
// A DVR window over every track: the relay keeps nothing of a room whose objects are sealed.
const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 }, dvr: { path: path.join(dir, "dvr-store"), max_age_s: 900, max_bytes: 256 * 1024 * 1024 } }, dir, "relay");
const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 4, e2ee: true } }, dir);
const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws });
const base = `${srv.url}/room/?service=/room-api`;
const url = (extra = {}) => { const u = new URL(base); for (const [kk, v] of Object.entries(extra)) u.searchParams.set(kk, v); return u.toString(); };
const browser = await launch(dir);
const pages = [];
const open = async (name, extra) => {
const p = await page(browser, url(extra), name, dir);
pages.push(p);
await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
return p;
};
const enter = async (name, extra, role, phase) => {
const p = await open(name, extra);
if (role === "publisher") await p.click("#join"); else await p.evaluate((r) => { window.roomTest.enter(r).catch(() => {}); }, role);
const s = await waitPhase(p, phase);
return { p, principal: s.me.principal, room: s.room };
};
const snap = async (p) => { const s = await p.snap(); if (s.errors.length) throw new Error(`${p.name}: ${s.errors[0]}`); return s; };
const ep = (s) => s.e2ee && s.e2ee.member.epoch;
try {
// Before anyone enters, the page says the room is end-to-end encrypted, and offers no
// watching and no seeking back.
const first = await open("alice", {});
const banner = await until("the page shows the room is end-to-end encrypted", STEP, () => first.evaluate(() => {
const e = document.getElementById("e2ee");
return e && !e.hidden && { text: e.textContent, watch: document.getElementById("watch").hidden, dvr: document.getElementById("dvr").hidden };
}), 200);
check(`${label}: the page says the room is end-to-end encrypted before entering, with no watching and no seeking back`, /End-to-end encrypted/.test(banner.text) && banner.watch && banner.dvr, banner.text.replace(/\s+/g, " ").slice(0, 160));
await first.click("#join");
const sa0 = await waitPhase(first, "publishing");
const A = { p: first, principal: sa0.me.principal, room: sa0.room };
const alice = A.p;
// Alice is the first present: the room owes her the group, which she creates through
// the service's log.
await until(`${label} alice creates the group`, STEP, async () => ep(await snap(alice)) === 0, 250);
const B = await enter("bob", {}, "publisher", "publishing");
const bob = B.p;
const keyed = await until(`${label} bob joins the group by alice's commit`, STEP, async () => {
const [x, y] = [await snap(alice), await snap(bob)];
return ep(x) != null && ep(x) === ep(y) && ep(y) >= 1 && [x, y];
}, 250);
const sb0 = keyed[1];
check(`${label}: the room's record checks, and bob joins the group alice created, by her commit's Welcome`, sb0.e2ee.suite === 4 && sb0.e2ee.mls.joined === 1 && keyed[0].e2ee.mls.created === 1 && keyed[0].e2ee.posts.ok >= 2,
`suite ${sb0.e2ee.suite}; alice: ${JSON.stringify(keyed[0].e2ee.mls)}, ${keyed[0].e2ee.posts.ok} posts carried; bob: ${JSON.stringify(sb0.e2ee.mls)}, epoch ${ep(sb0)}`);
// Media both ways: every object verified against its receipt (over the sealed bytes),
// opened, and only then decoded.
for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) {
await until(`${label} ${p.name} decodes the other's media`, STEP * 2, async () => {
const s = await snap(p);
const t = s.subs[other];
return t && t.video.decoded >= 30 && t.audio.decoded >= 50 && t.video.e2ee && t.video.e2ee.opened >= 30;
}, 500);
}
for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) {
const s = await snap(p);
mediaChecks(`${label} ${p.name}`, s, other, { minVideo: 30, minAudio: 50 });
const v = s.subs[other].video.e2ee, a = s.subs[other].audio.e2ee;
const others = Object.keys({ ...v.refused, ...a.refused }).filter((r) => r !== "epoch_not_joined");
check(`${label}: ${p.name} opens every object of the other's before it decodes it, and refuses none but those sealed before its join`, v.opened >= 30 && a.opened >= 50 && others.length === 0 && s.subs[other].video.decoded <= v.opened && s.subs[other].audio.decoded <= a.opened * 3,
`video ${v.opened} opened (by epoch ${JSON.stringify(v.by_epoch)}), ${s.subs[other].video.decoded} decoded, refused ${JSON.stringify(v.refused)}, ${v.held} held, ${v.held_expired} expired; audio ${a.opened} opened, refused ${JSON.stringify(a.refused)}`);
}
const sa = await snap(alice);
check(`${label}: alice sealed every frame she sent once keyed, and sent nothing before`, sa.e2ee.sealed.video >= 30 && sa.e2ee.sealed.audio >= 50 && Math.abs(sa.published.video - sa.e2ee.sealed.video) <= 3 && Math.abs(sa.published.audio - sa.e2ee.sealed.audio) <= 3,
`sealed ${JSON.stringify(sa.e2ee.sealed)} (by epoch ${JSON.stringify(sa.e2ee.sealed_by_epoch)}); published ${sa.published.video} video, ${sa.published.audio} audio; ${JSON.stringify(sa.e2ee.unkeyed)} encoded before her group existed, not sent`);
const dom = sa.e2ee.dom || "";
check(`${label}: the page shows the room is end-to-end encrypted, its epoch and its two members`, new RegExp(`epoch ${ep(sa)}`).test(dom) && /2 members/.test(dom) && /you/.test(dom), dom.replace(/\s+/g, " ").slice(0, 200));
const seek = await alice.evaluate(() => window.roomTest.seekBack(5).then(() => "seeked", (e) => String(e.message || e)));
check(`${label}: there is no seeking back in such a room`, /no seeking back/.test(seek), seek);
// A subscriber outside the group (a viewer, never a member): it receives the sealed
// bytes exactly as alice sealed them, and opens nothing.
const V = await enter("viewer", {}, "viewer", "watching");
const sv = await until(`${label} the viewer receives alice's sealed video`, STEP * 2, async () => {
const s = await snap(V.p);
const t = s.subs[A.principal];
return t && t.video.received >= 15 && t.video.e2ee && t.video.e2ee.samples.length >= 5 && s;
}, 500);
const tv = sv.subs[A.principal].video;
const sent = await alice.evaluate((g) => window.roomTest.e2eeSent(g), tv.e2ee.samples.map((x) => x.group));
const same = tv.e2ee.samples.filter((x) => sent.some((y) => y.group === x.group && y.sealed_head === x.sealed_head && y.sealed_len === x.sealed_len));
const sizes = sent.every((y) => y.sealed_len === sealedLen(y.plain_len) && y.plain_head.startsWith(WAI1) && !y.sealed_head.includes(WAI1) && y.sealed_head !== y.plain_head);
check(`${label}: a subscriber outside the group receives exactly the bytes alice sealed: no envelope, the AEAD's length, nothing it can open`, same.length >= 3 && same.length === sent.length && sizes && tv.decoded === 0 && (tv.envelope_errors || 0) === 0 && tv.e2ee.opened === 0 && Object.keys(tv.e2ee.refused).every((r) => r === "epoch_not_joined"),
`${same.length} of ${tv.e2ee.samples.length} sampled groups byte for byte as sealed (${sent.length} of them still on alice's record); ${tv.received} received, ${tv.decoded} decoded, ${tv.envelope_errors || 0} handed to the decode path, ${tv.e2ee.opened} opened, refused ${JSON.stringify(tv.e2ee.refused)}; e.g. plain ${sent[0] ? sent[0].plain_head.slice(0, 16) : "-"}… sealed ${sent[0] ? sent[0].sealed_head.slice(0, 16) : "-"}…`);
// Every change a relay could make to an object, presented to bob before he opens it,
// then the object as it came, then once more.
const ts = await bob.evaluate((P) => window.roomTest.e2eeTamperSuite(P), A.principal);
const want = { payload_byte_changed: "bad_tag", priority_changed: "bad_tag", moved_to_another_group: "bad_tag", key_id_removed: "key_id_missing", key_id_outside_too: "properties_malformed", object_id_past_32_bits: "object_id_too_large", kid_of_an_empty_leaf: "key_id_unknown", kid_of_this_member: "sender_not_owner", as_sent: "opened", again: "replayed" };
const wrong = Object.entries(want).filter(([kk, v]) => ts.outcomes[kk] !== v);
check(`${label}: each change a relay could make to an object is refused with the extension's reason, and the object itself opens once`, wrong.length === 0, `group ${ts.group}, epoch ${ts.epoch}: ${Object.entries(ts.outcomes).map(([kk, v]) => `${kk} ${v}`).join(", ")}`);
// On the wire: alice's next video object has a byte changed after sealing, its receipt
// over the bytes as they travel, so the relay's door passes it; bob refuses it.
const vb = (await snap(bob)).subs[A.principal].video;
const badBefore = vb.e2ee.refused.bad_tag || 0;
await alice.evaluate(() => window.roomTest.e2eeTamperNext());
const tampered = await until(`${label} alice sends a changed object`, STEP, async () => { const s = await snap(alice); return s.e2ee.tampered.length === 1 && s.e2ee.tampered[0]; }, 200);
const va = await until(`${label} bob refuses it`, STEP, async () => { const v = (await snap(bob)).subs[A.principal].video; return (v.e2ee.refused.bad_tag || 0) > badBefore && v; }, 250);
const badAfter = va.e2ee.refused.bad_tag;
// What reaches the decode path is read as a WAI1 envelope first: sealed bytes never read
// as one. (A decoder may still report the frame after the dropped one, which refers to it.)
check(`${label}: an object changed on the wire is refused bad_tag and never reaches a decoder`, badAfter === badBefore + 1 && (va.envelope_errors || 0) === (vb.envelope_errors || 0), `group ${tampered}: bad_tag ${badBefore} → ${badAfter}; envelopes that did not read ${vb.envelope_errors || 0} → ${va.envelope_errors || 0}; decoder errors ${vb.decode_errors} → ${va.decode_errors}`);
// Carol joins late and fetches the earlier groups: nothing sealed before her join opens.
const C = await enter("carol", { fetch: "1" }, "publisher", "publishing");
const carol = C.p;
const three = await until(`${label} carol joins`, STEP * 2, async () => {
const ss = [await snap(alice), await snap(bob), await snap(carol)];
return ss.every((x) => ep(x) != null && ep(x) === ep(ss[0])) && ss;
}, 250);
const joinedAt = three[2].e2ee.joined;
check(`${label}: carol joins at a new epoch, and every page holds the same roster`, joinedAt > ep(sa) && three.every((x) => Object.keys(x.e2ee.member.roster).length === 3) && JSON.stringify(three[0].e2ee.member.roster) === JSON.stringify(three[2].e2ee.member.roster),
`epoch ${joinedAt}; roster ${JSON.stringify(three[2].e2ee.member.roster).slice(0, 220)}`);
const late = await until(`${label} carol's fetch of the earlier groups is accounted for`, STEP * 2, async () => {
const s = await snap(carol);
const t = s.subs[A.principal] && s.subs[A.principal].video;
return t && t.fetched >= 15 && t.e2ee && t.e2ee.fetched_opened + sum(t.e2ee.fetched_refused) >= t.fetched - 2 && t.decoded > 0 && t;
}, 500);
const fe = late.e2ee;
const early = Object.keys(fe.fetched_epochs).map(Number).filter((e) => e < joinedAt);
check(`${label}: a late joiner opens nothing sealed before its join: every earlier group it fetched is refused epoch_not_joined`, (fe.fetched_refused.epoch_not_joined || 0) >= 10 && early.length === 0 && Object.keys(fe.fetched_refused).every((r) => r === "epoch_not_joined"),
`${late.fetched} fetched (groups ${late.first_fetched_group}..${late.last_fetched_group}; ${late.fetched_live || 0} more already received live): ${fe.fetched_opened} opened (epochs ${JSON.stringify(fe.fetched_epochs)}), refused ${JSON.stringify(fe.fetched_refused)}; live ${late.decoded} decoded`);
// Carol keeps back an object sealed under her epoch, then stops publishing and stays:
// the clock records her leave and proposes her removal, and a member commits it.
const kept = await carol.evaluate(() => window.roomTest.e2eeSealAsMe(4000000000));
const ended = await carol.evaluate(() => window.roomTest.e2eeStopPublishing());
const removed = await until(`${label} carol is removed`, STEP, async () => {
const ss = [await snap(alice), await snap(bob), await snap(carol)];
return ss[2].e2ee.removed != null && ep(ss[0]) === ss[2].e2ee.removed && ep(ss[1]) === ep(ss[0]) && ss;
}, 250);
const re = removed[2].e2ee.removed;
check(`${label}: a member that stops publishing is removed by the next commit, and keeps its session`, ended === 4 && removed[2].phase === "publishing" && Object.keys(removed[0].e2ee.member.roster).length === 2 && removed[2].e2ee.member.epoch == null,
`${ended} tracks ended; epoch ${re}; alice's roster ${Object.keys(removed[0].e2ee.member.roster).length}; carol ${removed[2].phase}`);
// At once, while alice still holds the epoch carol sealed it in (§5.8).
const sr = await alice.evaluate((o) => window.roomTest.e2eeTryOpen(o), kept);
check(`${label}: an object carol sealed before her removal is refused sender_removed by a member who applied it`, sr.reason === "sender_removed", `sealed in epoch ${kept && kept.epoch}: ${JSON.stringify(sr)}`);
const cut = await until(`${label} carol receives the new epoch and opens none of it`, STEP * 2, async () => {
const s = await snap(carol);
const t = s.subs[A.principal].video;
return (t.e2ee.refused.removed_before_epoch || 0) >= 10 && t;
}, 500);
const after = Object.keys(cut.e2ee.by_epoch).map(Number).filter((e) => e >= re);
check(`${label}: a removed member still receives the room's objects and opens none of a later epoch`, after.length === 0 && (cut.e2ee.refused.removed_before_epoch || 0) >= 10,
`refused ${JSON.stringify(cut.e2ee.refused)}; opened by epoch ${JSON.stringify(cut.e2ee.by_epoch)}`);
for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) {
const t = await until(`${label} ${p.name} opens the other's objects of epoch ${re}`, STEP, async () => { const x = (await snap(p)).subs[other].video; return (x.e2ee.by_epoch[re] || 0) >= 10 && x; }, 500);
check(`${label}: ${p.name} opens the other's objects of the epoch that excludes carol`, true, `${t.e2ee.by_epoch[re]} opened in epoch ${re}`);
}
// The relay: its door checked every object against its receipt, and its DVR store keeps
// nothing of the room.
const sealedLine = relay.lines.find((l) => l.includes("a room's objects are sealed") && l.includes(A.room));
check(`${label}: the relay's DVR store keeps nothing of the room`, !!sealedLine, (sealedLine || "no line").replace(/^.*INFO /, "").slice(0, 200));
const refusedAtDoor = relay.lines.filter((l) => l.includes("refused at the door")).length;
check(`${label}: the relay's door verified every object against its receipt over the sealed bytes, and refused none`, refusedAtDoor === 0, `${refusedAtDoor} refused`);
for (const p of pages) {
const s = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1));
check(`${label}: ${p.name} reports no error`, s.errors.length === 0 && !(s.e2ee && s.e2ee.stopped), [...s.errors.slice(0, 2), s.e2ee && s.e2ee.stopped].filter(Boolean).join(" | "));
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]);
for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave()));
await bounded(browser.close());
await bounded(srv.close());
await bounded(svc.stop());
await bounded(relay.stop());
killStrays();
}
}
// ------------------------------------------------------------------------------------- age
// RFC 8785's canonical form of a record of age-assurance §2: members by name, no white space.
function jcs(v) {
if (Array.isArray(v)) return `[${v.map(jcs).join(",")}]`;
if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${jcs(v[k])}`).join(",")}}`;
return JSON.stringify(v);
}
// Whether a record of age-assurance verifies under its `signer_pubkey` (§2): Ed25519 over the
// domain and the JCS form of the record without `sig`.
function recordVerifies(rec) {
const { sig, ...body } = rec;
const key = crypto.createPublicKey({ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(rec.signer_pubkey, "hex")]), format: "der", type: "spki" });
return crypto.verify(null, Buffer.concat([Buffer.from("wai:age-assurance\x01", "latin1"), Buffer.from(jcs(body), "utf8")]), key, Buffer.from(sig, "hex"));
}
async function scenarioAge(run) {
const label = "age";
const dir = path.join(OUT, `run${run}-age`);
fs.mkdirSync(dir, { recursive: true });
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the age scenario");
const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json"));
const issuer = await startIssuer(keys);
const k = roomKeys(ROOM, dir);
const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 }));
const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay");
const byIssuer = {};
for (const x of keys) (byIssuer[x.issuer] = byIssuer[x.issuer] || []).push(`{ threshold = ${x.threshold}, spki = "${x.spki}" }`);
let age = "[age]\ntoken_window_s = 600\n";
for (const [name, ks] of Object.entries(byIssuer)) age += `[[age.issuers]]\nname = "${name}"\nkeys = [${ks.join(", ")}]\n`;
age += `[[age.ratings]]\nscheme = "https://rating-a.example/scheme"\nvalue = "16"\nmin_age = 16\n`;
const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 }, rate: { client_burst: 40, client_every_s: 1 }, extra: age }, dir);
const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws });
const base = `${srv.url}/room/?service=/room-api&issuer_directory=${encodeURIComponent(issuer.url)}`;
const url = (extra = {}) => { const u = new URL(base); for (const [kk, v] of Object.entries(extra)) u.searchParams.set(kk, v); return u.toString(); };
const browser = await launch(dir);
const pages = [];
const enter = async (name, button, extra, phase) => {
const p = await page(browser, url(extra), name, dir);
pages.push(p);
await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await p.click(button);
const st = await until(`${name} ${phase}`, STEP, async () => {
const x = await p.snap();
if (x && x.errors && x.errors.length) throw new Error(`${name}: ${x.errors[0]}`);
return x && x.phase === phase && x;
}, 200);
return { p, principal: st.me && st.me.principal, s: st };
};
try {
// The performer: a token of 18, and its own video rated 18.
const A = await enter("performer", "#join", { age_threshold: "18", rate: "18", rate_scheme: "https://rating-a.example/scheme", rate_value: "18" }, "publishing");
check(`${label}: the performer answers the service's challenge with a token it obtains by blind issuance, and enters at 18`, A.s.age && A.s.age.admitted === 18 && A.s.age.issuer === "issuer-b.example" && A.s.grant.role === "publisher", `admitted at ${A.s.age && A.s.age.admitted} by ${A.s.age && A.s.age.issuer}`);
// A viewer at 16: the audio, verified and decoded; the video, withheld by the relay.
const V16 = await enter("viewer16", "#watch", { age_threshold: "16" }, "watching");
const s16 = await until(`${label} the viewer at 16 decodes the audio, the video withheld`, STEP * 2, async () => {
const x = await V16.p.snap();
if (x.errors.length) throw new Error(x.errors[0]);
const t = x.subs[A.principal];
return t && t.audio.decoded >= 30 && (t.withheld || []).includes("video") && x;
}, 500);
const t16 = s16.subs[A.principal];
check(`${label}: a viewer admitted at 16 is served the room's audio (rated 16), every object verified`, t16.audio.verified >= 30 && t16.audio.refused === 0 && s16.age.admitted === 16, `${t16.audio.verified} verified, ${t16.audio.decoded} decoded`);
const dom = await V16.p.evaluate(() => document.getElementById("status").textContent);
check(`${label}: the relay refuses a viewer at 16 the video its performer rates 18 (below_threshold), and the page says it is withheld`, t16.video.received === 0 && t16.video.decoded === 0 && s16.age.withheld.some((w) => w.principal === A.principal && w.track === "video") && /withheld/.test(dom), dom);
// A viewer at 18: both.
const V18 = await enter("viewer18", "#watch", { age_threshold: "18" }, "watching");
const s18 = await until(`${label} the viewer at 18 decodes the video and the audio`, STEP * 2, async () => {
const x = await V18.p.snap();
if (x.errors.length) throw new Error(x.errors[0]);
const t = x.subs[A.principal];
return t && t.video.decoded >= 20 && t.audio.decoded >= 30 && x;
}, 500);
const t18 = s18.subs[A.principal];
check(`${label}: a viewer admitted at 18 is served the video and the audio, every object verified`, t18.video.refused === 0 && t18.video.verified >= 20 && !(t18.withheld || []).length, `${t18.video.verified} video and ${t18.audio.verified} audio verified`);
// A viewer whose token is of 13: refused by the service, below the room's 16.
const V13 = await enter("viewer13", "#watch", { age_threshold: "13" }, "refused");
check(`${label}: a viewer whose token is of 13 is refused by the service, below the room's 16 (below_threshold)`, V13.s.age.answer === "below_threshold" && !V13.s.grant, `the service answered ${V13.s.age.answer}`);
// The viewer at 16's token, presented again: spent.
const again = await fetch(`${srv.url}/room-api/grant`, { method: "POST", headers: { "content-type": "application/json", authorization: `PrivateToken token="${s16.age.token}"` }, body: JSON.stringify({ principal: crypto.generateKeyPairSync("ed25519").publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("hex"), role: "viewer" }) });
const ab = await again.json().catch(() => ({}));
check(`${label}: a token presented a second time is refused (token_replayed)`, again.status === 403 && ab.error === "token_replayed", `${again.status} ${JSON.stringify(ab)}`);
// The admission receipt: signed by the room's clock, counting each admission by window,
// issuer and threshold, every cell under 50 suppressed.
const rr = await (await fetch(`${srv.url}/room-api/age/receipt`)).json();
const rec = JSON.parse(rr.receipt);
const cells = rec.cells.map((c) => `${c.issuer}/${c.threshold}/${c.admitted}/${c.suppressed}`);
check(`${label}: the admission receipt verifies under the room's clock and suppresses every count under 50`, recordVerifies(rec) && rec.signer_pubkey === k.clock && rec.kind === "wai-age-admissions" && JSON.stringify(cells) === JSON.stringify(["issuer-a.example/16/0/1", "issuer-b.example/18/0/1"]) && Object.keys(rec).length === 10, JSON.stringify(cells));
check(`${label}: the test issuer signed a token for each page, blindly`, issuer.issued.length === 4, JSON.stringify(issuer.issued));
for (const p of pages) {
const x = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1));
check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | "));
}
const held = relay.lines.filter((l) => l.includes("a rating claim held")).length;
const below = relay.lines.filter((l) => l.includes("below_threshold")).length;
check(`${label}: the relay held the room's rating and the performer's, and refused below the threshold`, held >= 2 && below >= 1, `${held} claims held, ${below} refusals logged`);
const refusedAtDoor = relay.lines.filter((l) => l.includes("refused at the door")).length;
check(`${label}: the relay's door verified every media object and refused none`, refusedAtDoor === 0, `${refusedAtDoor} refused`);
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]);
for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave()));
await bounded(browser.close());
await bounded(srv.close());
await bounded(svc.stop());
await bounded(relay.stop());
await bounded(issuer.close());
killStrays();
}
}
// ---------------------------------------------------------------------------------- origin
// The endpoints a link may name: a relay, a room service or a GOAWAY's new session on another
// origin are refused, and the page connects to nothing there (./web/room/endpoint.js).
async function scenarioOrigin(run) {
const label = "origin";
const dir = path.join(OUT, `run${run}-origin`);
fs.mkdirSync(dir, { recursive: true });
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the origin scenario");
const k = roomKeys(ROOM, dir);
const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 }));
const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay");
const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 } }, dir);
const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws });
// The fixture room of the GOAWAY step: its relay names a relay on another origin when it stops.
const { fixture: fx, file } = makeFixture(WAI, ["alice"], dir);
const away = "wss://relay.elsewhere.example/moq";
const moving = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: path.join(dir, "summaries-goaway.jsonl"), goaway_uri: away, goaway_timeout_ms: 10000 }, dir, "relay-goaway");
const fsrv = await serve(WEB, { "/fixture.json": file });
const browser = await launch(dir);
const pages = [];
const open = async (url, name) => {
const p = await page(browser, url, name, dir);
pages.push(p);
return p;
};
const statusOf = (p) => p.evaluate(() => document.getElementById("status").textContent);
try {
// A link that names a relay on another origin: the visitor presses Join, and the page
// refuses the relay before it asks for the camera, a grant or a session.
const foreign = "wss://relay.elsewhere.example/moq";
const A = await open(`${srv.url}/room/?service=/room-api&ws=${encodeURIComponent(foreign)}`, "foreign-relay");
await until(`${label} the page is ready`, STEP, () => A.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await sleep(1000);
const before = A.requests.length;
await A.click("#join");
const sa = await until(`${label} the page refuses the link's relay`, STEP, async () => { const x = await A.snap(); return x.phase === "refused" && x; }, 200);
const said = await statusOf(A);
check(`${label}: a link naming a relay on another origin is refused when the visitor joins, and the page says so`, sa.refused.length === 1 && sa.refused[0].name === "ws" && sa.refused[0].value === foreign && /not this page's own/.test(said) && /connects to nothing/.test(said), said);
await sleep(1500);
const asked = A.requests.slice(before);
check(`${label}: nothing connects: no WebSocket, no request after the press, no grant, no session`, A.sockets.length === 0 && asked.length === 0 && !A.requests.some((u) => u.includes("/grant")) && sa.transport == null && sa.grant == null, `${A.sockets.length} WebSockets, ${asked.length} requests after the press${asked.length ? `: ${asked.slice(0, 3).join(" ")}` : ""}`);
const cam = await A.evaluate(() => !!(window.roomTest.state.capture && window.roomTest.state.capture.stream));
check(`${label}: the camera and microphone are not opened`, !cam, `capture: ${cam}`);
// A link that names a room service on another origin, in the forms a link can hide it in:
// the page takes none, and offers no Join.
for (const [form, value] of [["scheme-relative", "//service.elsewhere.example/room-api"], ["with the page's host as user information", `http://${new URL(srv.url).host}@service.elsewhere.example/room-api`], ["javascript:", "javascript:alert(document.domain)"]]) {
const B = await open(`${srv.url}/room/?service=${encodeURIComponent(value)}`, `foreign-service-${form.replace(/[^a-z]+/gi, "-").replace(/-$/, "")}`);
const sb = await until(`${label} the page refuses the ${form} service`, STEP, async () => { const x = await B.snap(); return x && x.phase === "refused" && x; }, 200);
const join = await B.evaluate(() => document.getElementById("join").disabled);
await sleep(500);
const off = offOrigin(B, srv.url);
check(`${label}: a link naming a room service on another origin (${form}) is refused, Join stays off, and nothing leaves the page's origin`, sb.refused.length === 1 && sb.refused[0].name === "service" && join && off.length === 0 && B.sockets.length === 0, `${value} — ${sb.refused[0] && sb.refused[0].reason}; ${off.length} requests away`);
}
// A relay that stops with GOAWAY naming another origin: the page refuses to move there,
// ends its session and says so.
const u = new URL("/room/", fsrv.url);
u.searchParams.set("fixture", "/fixture.json");
u.searchParams.set("wt", moving.wt);
u.searchParams.set("ws", moving.wsUrl);
u.searchParams.set("hashes", (moving.hashes || [moving.hash]).join(","));
const uc = new URL(u);
uc.searchParams.set("role", "clock");
const clock = await open(uc.toString(), "goaway-clock");
await waitPhase(clock, "ready");
u.searchParams.set("who", "alice");
const C = await open(u.toString(), "goaway-elsewhere");
await waitPhase(C, "publishing");
moving.proc.kill("SIGINT");
const sc = await until(`${label} the page refuses the GOAWAY`, STEP, async () => { const x = await C.snap(); return x.goaways.length && x.goaways[0].refused && x.phase === "ended" && x; }, 250);
await until(`${label} the clock's page refuses the GOAWAY too`, STEP, async () => { const x = await clock.snap(); return x.goaways.length && x.goaways[0].refused && x; }, 250);
const saidC = await statusOf(C);
check(`${label}: a GOAWAY naming another origin is refused, the session ends, and the page says so`, sc.goaways[0].uri === away && !sc.goaways[0].moved && /refused/.test(saidC), `${sc.goaways[0].uri}: ${sc.goaways[0].refused}; "${saidC.slice(0, 120)}"`);
check(`${label}: after the refused GOAWAY nothing connects elsewhere`, offOrigin(C, fsrv.url).filter((x) => !x.startsWith("ws://127.0.0.1") && !x.startsWith("https://127.0.0.1")).length === 0 && !C.sockets.some((x) => x.includes("elsewhere")), `${C.sockets.length} WebSockets: ${C.sockets.join(" ")}`);
const stopped = await moving.stop(null, 30000);
const line = moving.lines.find((l) => /stopped; \d+ session\(s\) closed at the GOAWAY timeout/.test(l)) || "";
check(`${label}: the page closed its session itself, before the GOAWAY timeout`, Number((line.match(/stopped; (\d+)/) || [])[1]) === 0, `${line.trim()} (${JSON.stringify(stopped)})`);
for (const p of pages) {
const x = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1));
check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | "));
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]);
await bounded(browser.close());
await bounded(srv.close());
await bounded(fsrv.close());
await bounded(svc.stop());
await bounded(relay.stop());
if (moving.proc.exitCode == null) await bounded(moving.stop("SIGKILL"));
killStrays();
}
}
// ------------------------------------------------------------ age assurance × co-performance
// A room that admits by age and keeps co-performance. The room's clock follows and logs the
// performers' rated tracks over a session of its own, which holds no grant and no token: it
// proves the room's keys in its SETUP, and the relay serves it as the room's own service
// (WAI age-assurance §5.5), while a viewer stays held to the serving rule.
async function scenarioAgeCo(run) {
const label = "ageco";
const dir = path.join(OUT, `run${run}-ageco`);
fs.mkdirSync(dir, { recursive: true });
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the ageco scenario");
const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json"));
const issuer = await startIssuer(keys);
const k = roomKeys(ROOM, dir);
const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 }));
const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: `${k.lines}\n[floor]\nroom_keys = ["${k.clock}"]\n`, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay");
const byIssuer = {};
for (const x of keys) (byIssuer[x.issuer] = byIssuer[x.issuer] || []).push(`{ threshold = ${x.threshold}, spki = "${x.spki}" }`);
let age = "[age]\ntoken_window_s = 600\n";
for (const [name, ks] of Object.entries(byIssuer)) age += `[[age.issuers]]\nname = "${name}"\nkeys = [${ks.join(", ")}]\n`;
age += `[[age.ratings]]\nscheme = "https://rating-a.example/scheme"\nvalue = "16"\nmin_age = 16\n`;
const svc = await startRoomService(ROOM, {
keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 }, rate: { client_burst: 40, client_every_s: 1 },
co: { audience_input: 2, latency_budget_ms: 1500, lead: '"first"', ai: true, fidelity_every_s: 0, commit_after_ms: 1200, lead_ms: 300 },
top: { public_prefix: '"/room-api"' }, extra: age,
}, dir);
const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws });
const base = `${srv.url}/room/?service=/room-api&issuer_directory=${encodeURIComponent(issuer.url)}`;
const url = (extra = {}) => { const u = new URL(base); for (const [kk, v] of Object.entries(extra)) u.searchParams.set(kk, v); return u.toString(); };
const browser = await launch(dir);
const pages = [];
const enter = async (name, button, extra, phase) => {
const p = await page(browser, url(extra), name, dir);
pages.push(p);
await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await p.click(button);
const st = await until(`${name} ${phase}`, STEP, async () => {
const x = await p.snap();
if (x && x.errors && x.errors.length) throw new Error(`${name}: ${x.errors[0]}`);
return x && x.phase === phase && x;
}, 200);
return { p, principal: st.me && st.me.principal, s: st };
};
const co = (p) => p.evaluate(() => window.roomTest.coState());
try {
// The lead at 18, its own video rated 18; the AI participant at 18.
const A = await enter("lead", "#join", { age_threshold: "18", rate: "18", rate_scheme: "https://rating-a.example/scheme", rate_value: "18" }, "publishing");
const B = await enter("ai", "#join", { ai: "scripted", script: "off", age_threshold: "18" }, "publishing");
check(`${label}: both performers enter by a token of 18, in a room with co-performance`, A.s.age && A.s.age.admitted === 18 && B.s.age && B.s.age.admitted === 18 && !!(await co(A.p)), `admitted at ${A.s.age && A.s.age.admitted} and ${B.s.age && B.s.age.admitted}`);
const proved = relay.lines.filter((l) => l.includes("the session is the room's own service")).length;
check(`${label}: the room's clock proves the room's keys, and the relay takes its session for the room's own service`, proved >= 1, `${proved} room key(s) proved`);
// A viewer at 16: held to the serving rule, the video its performer rates 18 withheld.
const V16 = await enter("viewer16", "#watch", { age_threshold: "16" }, "watching");
const s16 = await until(`${label} the viewer at 16 decodes the audio, the video withheld`, STEP * 2, async () => {
const x = await V16.p.snap();
if (x.errors.length) throw new Error(x.errors[0]);
const t = x.subs[A.principal];
return t && t.audio.decoded >= 30 && (t.withheld || []).includes("video") && x;
}, 500);
const t16 = s16.subs[A.principal];
check(`${label}: a viewer at 16 is still held to the serving rule: the lead's audio served, its video rated 18 withheld`, t16.audio.verified >= 30 && t16.video.received === 0 && s16.age.withheld.some((w) => w.principal === A.principal && w.track === "video"), `${t16.audio.verified} audio verified, ${t16.video.received} video received`);
// The clock logs both performers' objects, the lead's video rated 18 among them.
const logged = await until(`${label} the clock logs every performer's tracks`, STEP * 2, async () => {
const r = await (await fetch(`${srv.url}/room-api/co/objects?from=0`)).json();
const tracks = {};
for (const o of r.objects || []) (tracks[o.principal] = tracks[o.principal] || new Set()).add(o.track);
const n = (P) => (tracks[P] ? tracks[P].size : 0);
return n(A.principal) >= 2 && n(B.principal) >= 2 && { objects: (r.objects || []).length, lead: n(A.principal), ai: n(B.principal) };
}, 500);
check(`${label}: the room's clock logs the objects of both performers' tracks, the lead's video rated 18 among them`, logged.lead >= 2 && logged.ai >= 2, `${logged.objects} objects; tracks: lead ${logged.lead}, AI ${logged.ai}`);
// Turns from the lead's page; the other plays out of turn and the clock marks it, which it
// can only do holding the rated objects.
await A.p.fill("#co-turn-s", "5");
await A.p.fill("#co-turns-n", "6");
await A.p.selectOption("#co-mode", "mark");
await A.p.click("#co-start");
const sched = await until(`${label} both performers' pages hold the schedule`, STEP, async () => {
const ss = await Promise.all([A, B].map((x) => co(x.p)));
return ss.every((x) => x.schedules.length === 1) && ss[0].schedules[0];
}, 300);
const holders = sched.segments[0].holders;
const P2 = holders[0] === A.principal ? B : A;
const seg = sched.segments[0];
const waitRoom = async (p, x) => { for (;;) { const nn = await p.evaluate(() => window.roomTest.now()); if (nn >= x) return nn; await sleep(Math.min(500, (x - nn) / 1000)); } };
const before = (await co(A.p)).marks.out_of_turn;
await waitRoom(A.p, seg.start + 2_000_000);
await P2.p.evaluate(() => window.roomTest.coPlayOutOfTurn(500));
const marked = await until(`${label} an object out of turn is marked`, STEP, async () => { const c = await co(A.p); return c.marks.out_of_turn > before && c.marks; }, 300);
check(`${label}: a performer playing out of turn in a rated room is marked by the room's clock`, marked.out_of_turn > 0, `${marked.out_of_turn} out-of-turn mark(s)`);
const below = relay.lines.filter((l) => l.includes("below_threshold")).length;
check(`${label}: the relay refused below the threshold only where the rule holds`, below >= 1, `${below} refusal(s) logged`);
for (const p of pages) {
const x = await p.snap();
fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1));
check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | "));
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]);
for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave()));
await bounded(browser.close());
await bounded(srv.close());
await bounded(svc.stop());
await bounded(relay.stop());
await bounded(issuer.close());
killStrays();
}
}
// -------------------------------------------------------------------------- co-performance
async function scenarioCo(run) {
const label = "co";
const dir = path.join(OUT, `run${run}-co`);
fs.mkdirSync(dir, { recursive: true });
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the co scenario");
const k = roomKeys(ROOM, dir);
const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 }));
// The relay holds the floor and the audience's inputs, honouring the clock's statements.
const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: `${k.lines}\n[floor]\nroom_keys = ["${k.clock}"]\n`, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay");
const BUDGET_MS = 1500;
const svc = await startRoomService(ROOM, {
keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 }, rate: { client_burst: 20 },
co: { audience_input: 2, latency_budget_ms: BUDGET_MS, lead: '"first"', ai: true, fidelity_every_s: 0, commit_after_ms: 1200, lead_ms: 300 },
top: { public_prefix: '"/room-api"' },
}, dir);
const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws });
const base = `${srv.url}/room/?service=/room-api`;
const url = (extra = {}) => { const u = new URL(base); for (const [kk, v] of Object.entries(extra)) u.searchParams.set(kk, v); return u.toString(); };
const browser = await launch(dir);
const pages = [];
const enter = async (name, button, extra, phase) => {
const p = await page(browser, url(extra), name, dir);
pages.push(p);
await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await p.click(button);
const s = await waitPhase(p, phase);
return { p, principal: s.me.principal, room: s.room };
};
const co = (p) => p.evaluate(() => window.roomTest.coState());
const text = (p, sel) => p.evaluate((x) => (document.querySelector(x) || {}).textContent || "", sel);
try {
const A = await enter("lead", "#join", {}, "publishing");
const B = await enter("ai", "#join", { ai: "scripted", script: "off" }, "publishing");
const V = [];
for (const n of ["audience1", "audience2", "audience3"]) V.push(await enter(n, "#watch", {}, "watching"));
const all = [A, B, ...V];
check(`${label}: everyone is in one room`, all.every((x) => x.room === A.room), all.map((x) => x.room.slice(0, 8)).join(" "));
// The page reads the room's policy and its audience's grants: the section shows, the lead's
// controls on the lead's page alone, and the AI participant is shown as an AI system.
const sa = await co(A.p);
check(`${label}: the page reads the room's co-performance policy`, !!sa && sa.policy.ok && sa.policy.window_ms > 0 && sa.policy.modes.includes("mark") && sa.policy.modes.includes("refuse"), JSON.stringify(sa && sa.policy).slice(0, 200));
const leads = await Promise.all(all.map((x) => x.p.evaluate(() => !document.getElementById("co-lead").hidden)));
check(`${label}: the lead's page alone shows the lead's controls`, leads[0] && !leads.slice(1).some(Boolean), JSON.stringify(leads));
await until(`${label} the audience is recorded present`, STEP, async () => {
const s = await V[2].p.snap();
const present = (s.roomState && s.roomState.present || []).map((x) => x.principal);
return all.every((x) => present.includes(x.principal));
}, 300);
const named = await until(`${label} the lead's page names the AI participant`, STEP, async () => { const t = await A.p.evaluate((P) => window.roomTest.co.nameOf(P) + (window.roomTest.co.isAi(P) ? " (AI system)" : ""), B.principal); return /AI participant/.test(t) && /AI system/.test(t) && t; }, 300);
check(`${label}: an AI participant is shown as an AI system`, true, named);
// The lead starts turns from its page: two holders trading five-second turns, out of turn marked.
await A.p.fill("#co-turn-s", "5");
await A.p.fill("#co-turns-n", "6");
await A.p.selectOption("#co-mode", "mark");
await A.p.click("#co-start");
const sched = await until(`${label} every page holds the schedule`, STEP, async () => {
const ss = await Promise.all(all.map((x) => co(x.p)));
return ss.every((x) => x.schedules.length === 1) && ss[0].schedules[0];
}, 300);
const holders = sched.segments[0].holders;
check(`${label}: the schedule the lead asked for, signed by the room's clock, names both performers`, holders.length === 2 && holders.includes(A.principal) && holders.includes(B.principal) && sched.mode === "mark", `${sched.segments[0].turns} turns of ${sched.segments[0].turn_us} µs from ${sched.from}`);
const P1 = holders[0] === A.principal ? A : B, P2 = holders[0] === A.principal ? B : A;
const seg = sched.segments[0];
const turnStart = (i) => seg.start + i * seg.turn_us;
const waitRoom = async (p, x) => { for (;;) { const n = await p.evaluate(() => window.roomTest.now()); if (n >= x) return n; await sleep(Math.min(500, (x - n) / 1000)); } };
// During the first turn, every page says who holds the floor.
await waitRoom(A.p, turnStart(0) + 1_000_000);
const shown = await Promise.all(all.map((x) => x.p.evaluate(() => document.getElementById("co-now").dataset.holder)));
check(`${label}: every page shows who holds the floor`, shown.every((h) => h === P1.principal), `${shown.map((h) => h.slice(0, 6)).join(" ")}; "${(await text(V[0].p, "#co-now")).slice(0, 120)}"`);
// The other plays out of turn for half a second: the room's clock marks what it sent.
const before = (await co(V[0].p)).marks.out_of_turn;
await waitRoom(A.p, turnStart(0) + 2_000_000);
await P2.p.evaluate(() => window.roomTest.coPlayOutOfTurn(500));
const marked = await until(`${label} an object out of turn is marked`, STEP, async () => { const c = await co(V[0].p); return c.marks.out_of_turn > before && c.marks; }, 300);
check(`${label}: a performer playing out of turn under a schedule that marks is marked by the room's clock`, marked.out_of_turn > 0, `${marked.out_of_turn} out-of-turn mark(s); "${await text(V[0].p, "#co-marks")}"`);
// The AI participant's turn: a line it announces shows as upcoming before it plays, and is
// kept; a second, said otherwise, diverges. An object it sends late is marked late.
const aiTurn = holders.indexOf(B.principal);
await waitRoom(A.p, turnStart(aiTurn) + 300_000);
const l1 = await B.p.evaluate(() => window.roomTest.coAnnounce({ text: "the bridge comes next", inMs: 2000 }));
const up = await until(`${label} the audience sees the AI participant's next line ahead`, STEP, async () => {
const t = await V[1].p.evaluate((d) => { const li = document.querySelector(`#co-intents li[data-intent="${d}"]`); return li && li.dataset.outcome === "upcoming" && li.textContent; }, l1.intent);
return t;
}, 100);
check(`${label}: an AI participant's intent shows as its upcoming line before it plays`, /next, in/.test(up) && /the bridge comes next/.test(up) && /AI system/.test(up), up);
await B.p.evaluate(() => window.roomTest.coStampNextLate(3000));
const l2 = await B.p.evaluate(() => window.roomTest.coAnnounce({ text: "back to the top", inMs: 1500, say: "somewhere else entirely" }));
const outcomes = await until(`${label} both lines played`, STEP, async () => {
const c = await co(V[1].p);
const o = [l1.intent, l2.intent].map((d) => (c.intents.find((i) => i.digest === d) || {}).outcome);
return o.every((x) => x === "kept" || x === "diverged" || x === "missed") && o;
}, 300);
check(`${label}: an intent played as announced is kept, and one said otherwise diverges`, outcomes[0] === "kept" && outcomes[1] === "diverged", JSON.stringify(outcomes));
const late = await until(`${label} the late object is marked`, STEP, async () => { const c = await co(V[0].p); return c.marks.late > 0 && c.marks; }, 300);
check(`${label}: an object later than its budget is marked late`, late.late > 0, JSON.stringify(late));
// The audience: the lead opens a vote from its page; each member votes from the page's input box.
await A.p.fill("#co-q", "Which way next?");
await A.p.fill("#co-options-n", "3");
await A.p.fill("#co-window-s", "8");
await A.p.click("#co-vote");
const voteW = await until(`${label} the vote is open on every audience page`, STEP, async () => {
const ok = await Promise.all(V.map((x) => x.p.evaluate(() => !document.getElementById("co-input").hidden && document.querySelectorAll("#co-options button").length === 3)));
return ok.every(Boolean) && (await co(V[0].p)).windows.slice(-1)[0];
}, 200);
const choose = [2, 2, 1];
for (let i = 0; i < 3; i++) await V[i].p.click(`#co-options button[data-option="${choose[i] - 1}"]`);
await V[0].p.click(`#co-options button[data-option="1"]`); // its second, the last its power allows
const left = await until(`${label} the first member's inputs are spent`, STEP, async () => { const t = await text(V[0].p, "#co-left"); return /^0 of 2/.test(t) && t; }, 200);
check(`${label}: an audience member's input box counts its inputs against its power`, true, left);
// What §7.3 refuses, signed and sent but not kept in the sender's chain: the relay withholds each.
const detached = [
[V[0], { window: voteW.digest, type: "vote", option: 0 }, "input_over_limit"],
[V[1], { window: "ab".repeat(32), type: "vote", option: 0 }, "window_unknown"],
[V[2], { window: voteW.digest, type: "vote", option: 7 }, "input_type_mismatch"],
[V[2], { window: voteW.digest, type: "vote", option: 999 }, "co_statement_malformed"],
[A, { window: voteW.digest, type: "vote", option: 0 }, "input_not_permitted"],
];
const sentDigests = [];
for (const [x, d] of detached) sentDigests.push((await x.p.evaluate((o) => window.roomTest.coInputDetached(o), d)).digest);
const refusedLines = await until(`${label} the relay refuses each`, STEP, () => {
const got = detached.map(([, , why]) => relay.lines.filter((l) => l.includes("an audience input refused") && l.includes(why)).length);
return got.every((n) => n >= 1) && got;
}, 300);
check(`${label}: the relay refuses an audience input over its power, to an unknown window, of the wrong type, that does not read, or from a principal without the power`, true, detached.map(([, , w], i) => `${w}: ${refusedLines[i]}`).join(", "));
const sel = await until(`${label} the vote is committed to and selected from`, STEP * 2, async () => {
const c = await co(V[2].p);
const w = c.windows.find((x) => x.digest === voteW.digest);
return w && w.selection && w;
}, 300);
const selText = await V[2].p.evaluate((d) => (document.querySelector(`#co-selections li[data-window="${d}"]`) || {}).textContent || "", voteW.digest);
check(`${label}: the room's clock commits to the vote and selects the plurality, credited to its voters together`, sel.commit.count === 4 && sel.selection.picks[0].option === 1 && sel.selection.credits[0].count === 2 && /option 2, credited to its 2 voters together/.test(selText), `${sel.commit.count} committed; ${selText}`);
const leaked = await Promise.all(all.map((x) => x.p.evaluate((ds) => window.roomTest.state.timeline.filter((e) => ds.includes(e.digest)).length, sentDigests)));
check(`${label}: no page received an input the relay refused`, leaked.every((n) => n === 0), JSON.stringify(leaked));
// Prompts, selected by a draw from a seed the clock committed to when the window opened.
await A.p.fill("#co-q", "What happens next?");
await A.p.click("#co-prompts");
await until(`${label} the prompt box is open`, STEP, async () => (await Promise.all(V.map((x) => x.p.evaluate(() => !document.getElementById("co-input").hidden && !document.getElementById("co-prompt-row").hidden)))).every(Boolean), 200);
const prompts = ["a key change", "everyone stops", "the drums alone"];
for (let i = 0; i < 3; i++) {
await V[i].p.fill("#co-prompt", prompts[i]);
await V[i].p.press("#co-prompt", "Enter");
}
const drawn = await until(`${label} a prompt is drawn`, STEP * 2, async () => {
const c = await co(V[0].p);
const w = c.windows.slice(-1)[0];
return w && w.rule === "draw" && w.selection && w;
}, 300);
const drawnText = await V[0].p.evaluate((d) => (document.querySelector(`#co-selections li[data-window="${d}"]`) || {}).textContent || "", drawn.digest);
check(`${label}: a prompt drawn from the committed inputs is shown with its author's credit`, drawn.commit.count === 3 && drawn.selection.credits.length === 1 && drawn.selection.credits[0].step === "author" && prompts.some((x) => drawnText.includes(`“${x}”, credited to`)), drawnText);
// Under a schedule that refuses, the relay holds back what a performer plays out of turn.
const ref = await A.p.evaluate((h) => window.roomTest.coRequest("/co/schedule", { mode: "refuse", segments: [{ length: 4_000_000, turns: 2, holders: h }] }), holders);
check(`${label}: the lead's schedule that refuses is signed`, ref.status === 200, JSON.stringify(ref.body).slice(0, 160));
const sched2 = await until(`${label} the refusing schedule is held`, STEP, async () => { const c = await co(V[0].p); return c.schedules.length === 2 && c.schedules[1]; }, 300);
await waitRoom(A.p, sched2.segments[0].start + 1_500_000);
await P2.p.evaluate(() => window.roomTest.coPlayOutOfTurn(800));
await waitRoom(A.p, sched2.segments[0].start + 2 * sched2.segments[0].turn_us + 500_000);
// Fidelity, asked for by the lead once the lines are past the clock's lag.
const fid = await A.p.evaluate(() => window.roomTest.coRequest("/co/fidelity", {}));
const fidShown = await until(`${label} the fidelity statement shows`, STEP, async () => {
const t = await V[0].p.evaluate((P) => (document.querySelector(`#co-fidelity li[data-principal="${P}"]`) || {}).textContent || "", B.principal);
return /kept 1 of 2 announced/.test(t) && t;
}, 300);
check(`${label}: the room's clock states each participant's fidelity, shown on the page`, fid.status === 200 && fid.body.ok === true, fidShown);
// The room as an audience member's page holds it, and the clock's log of objects to the
// fidelity statement's instant: verified in the page and on the host, the same.
const fState = await until(`${label} the page holds the fidelity statement`, STEP, async () => { const c = await co(V[0].p); return c.fidelity && c.fidelity; }, 300);
// Every mark the clock owes is signed, and the page holds every record of the clock's.
const flushed = await until(`${label} the clock has signed every mark it owes`, STEP * 2, async () => {
const r = await (await fetch(`${srv.url}/room-api/co/state`)).json();
return r.marks_waiting === 0 && r;
}, 300);
await until(`${label} the page holds the clock's records`, STEP, async () => {
const rj = JSON.parse(await V[0].p.evaluate(() => window.roomTest.roomJson()));
const clockChain = rj.chains.find((c) => c.length && c[0].slice(74, 138) === k.clock);
return clockChain && clockChain.length >= flushed.clock_records;
}, 300);
await until(`${label} the page holds the whole room`, STEP, async () => { const s = await V[0].p.snap(); return s.roomState && s.roomState.verdict.outcome === "accept"; }, 300);
const log = await V[0].p.evaluate(() => window.roomTest.coObjects());
// The objects that arrived before the instant the clock owed no mark: each one it owes for
// them is in the chain the page holds.
const objects = log.filter((o) => o.arrival < flushed.now);
const inPage = await V[0].p.evaluate((o) => window.roomTest.coVerify(o), objects);
const roomJson = await V[0].p.evaluate(() => window.roomTest.roomJson());
const policy = await V[0].p.evaluate(() => window.roomTest.co.policyHex);
const onHost = hostCoVerify(WAI, roomJson, { policies: [policy], objects, payloads: [] }, dir);
fs.writeFileSync(path.join(dir, "co-verification.json"), JSON.stringify(onHost, null, 1));
const floor = onHost.floor || [];
const out = floor.filter((f) => f.floor === "out_of_turn");
const lateF = floor.filter((f) => f.late);
check(`${label}: the room with the clock's log verifies (co-performance §9): accept`, onHost.outcome === "accept", onHost.outcome === "accept" ? `${objects.length} objects, ${floor.length} placed` : `${onHost.reason} at ${JSON.stringify(onHost.at)}`);
check(`${label}: the page's verification of the room is the host's`, JSON.stringify(inPage) === JSON.stringify(onHost), inPage.outcome);
check(`${label}: every object out of turn and every late object is marked`, out.length > 0 && out.every((f) => f.marks & 1) && lateF.length > 0 && lateF.every((f) => f.marks & 2), `${out.length} out of turn, ${lateF.length} late`);
const intents = (onHost.intents || []).map((i) => i.outcome);
check(`${label}: the verification's intents and fidelity are the page's and the clock's`, JSON.stringify(intents) === JSON.stringify(["kept", "diverged"]) && JSON.stringify((onHost.fidelity || []).map((f) => [f.principal, f.kept, f.announced])) === JSON.stringify(fState.entries.map((e) => [e.principal, e.kept, e.announced])), `${intents.join(", ")}; ${JSON.stringify(onHost.fidelity)}`);
const wins = onHost.windows || [];
check(`${label}: both windows' selections recompute from their commitments`, wins.length === 2 && wins.every((w) => w.picks != null && w.commit), wins.map((w) => `${w.rule}: ${JSON.stringify(w.picks)}`).join("; "));
for (const x of pages) {
const sn = await x.snap();
fs.writeFileSync(path.join(dir, `${x.name}.snapshot.json`), JSON.stringify(sn, null, 1));
check(`${label}: ${x.name} reports no error`, sn.errors.length === 0, sn.errors.slice(0, 2).join(" | "));
}
} finally {
for (const x of pages) { try { x.dump(); } catch { /* closed */ } }
const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]);
for (const x of pages) await bounded(x.evaluate(() => window.roomTest.leave && window.roomTest.leave()));
await bounded(browser.close());
await bounded(srv.close());
await bounded(svc.stop());
await bounded(relay.stop());
killStrays();
}
// The relay's summaries: each refusal counted by reason, and what it held back out of turn.
const sums = fs.existsSync(path.join(dir, "summaries.jsonl")) ? fs.readFileSync(path.join(dir, "summaries.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
const refused = {};
let outOfTurn = 0;
for (const x of sums) {
const p = x.summary.tally.publisher;
for (const [kk, v] of Object.entries(p.co_refused || {})) refused[kk] = (refused[kk] || 0) + v;
outOfTurn += p.refused_out_of_turn || 0;
}
check(`${label}: the relay's summaries count each refused input by reason`, ["input_over_limit", "window_unknown", "input_type_mismatch", "co_statement_malformed", "input_not_permitted"].every((kk) => refused[kk] >= 1), JSON.stringify(refused));
check(`${label}: under the schedule that refuses, the relay held back objects out of turn`, outOfTurn > 0, `${outOfTurn} held back`);
}
// ---------------------------------------------------------------------------------- binding
const HERE_DIR = path.dirname(new URL(import.meta.url).pathname);
// The CPU energy the browser's process tree has spent so far, in nanojoules, or null where
// the host has no per-process counter (e2e/energy.py).
function treeEnergy() {
try {
const r = JSON.parse(execFileSync("python3", ["-I", path.join(HERE_DIR, "energy.py"), RUN_ID], { encoding: "utf8" }));
return r.nj != null ? r : null;
} catch {
return null;
}
}
// browser-sink §6 from the host's side: the page decodes VP9 in windows, this process reads
// the browser tree's counter around each, and around as many idle windows of the same
// length; the figure is the marginal energy over the busy windows, with its standard
// uncertainty from the spread of the windows' per-decode figures (GUM type A), rounded up.
async function measureDecodes(p, label) {
if (!treeEnergy()) return null;
await p.evaluate(() => window.bindingTest.prepareBurst());
const W = 3000, N = 4;
const idle = [], busy = [];
await p.evaluate((ms) => window.bindingTest.burst(ms), 500); // warm the decoder up
for (let i = 0; i < N; i++) {
const a = treeEnergy(); const ta = process.hrtime.bigint();
await sleep(W);
const b = treeEnergy(); const tb = process.hrtime.bigint();
idle.push({ nj: b.nj - a.nj, ns: Number(tb - ta) });
const c = treeEnergy(); const tc = process.hrtime.bigint();
const r = await p.evaluate((ms) => window.bindingTest.burst(ms), W);
const d = treeEnergy(); const td = process.hrtime.bigint();
busy.push({ nj: d.nj - c.nj, ns: Number(td - tc), decodes: r.decodes });
}
const idleRate = idle.reduce((k, x) => k + x.nj, 0) / idle.reduce((k, x) => k + x.ns, 0); // nJ per ns
const per = busy.map((x) => (x.nj - idleRate * x.ns) / x.decodes);
const mean = per.reduce((k, x) => k + x, 0) / per.length;
const sd = Math.sqrt(per.reduce((k, x) => k + (x - mean) ** 2, 0) / (per.length - 1));
const marginalNj = busy.reduce((k, x) => k + (x.nj - idleRate * x.ns), 0);
const decodes = busy.reduce((k, x) => k + x.decodes, 0);
const windowUs = Math.round(busy.reduce((k, x) => k + x.ns, 0) / 1000);
const raw = { idle, busy, per_decode_nj: per, idle_nj_per_s: idleRate * 1e9 };
if (!(marginalNj > 0) || !(mean > 0)) return { raw, measurement: null, why: "the busy windows drew no more than the idle ones" };
const measurement = {
joules_micro: Math.round(marginalNj / 1000), decodes, window_us: windowUs, class: "OnChipCounter",
relative_ppm: Math.ceil(((sd / Math.sqrt(per.length)) / mean) * 1e6), filtering: "Unknown",
coverage: "Partial", fabrics: ["cpu"],
};
console.log(`${label}: ${decodes} decodes over ${(windowUs / 1e6).toFixed(1)} s, ${(marginalNj / 1e6).toFixed(1)} mJ marginal over idle (${(idleRate * 1e9 / 1e6).toFixed(1)} mW idle)`);
return { raw, measurement };
}
async function scenarioBinding(run) {
const label = "binding";
const dir = path.join(OUT, `run${run}-binding`);
fs.mkdirSync(dir, { recursive: true });
if (!WAI_DIR) throw new Error("--wai-dir (or --corpus) is needed for the binding scenario");
const corpusFile = path.join(WAI_DIR, "live-conformance", "browser", "vectors.json");
const corpus = JSON.parse(fs.readFileSync(corpusFile, "utf8"));
const srv = await serve(WEB, {
"/vectors/browser.json": corpusFile,
"/vectors/int_motion.wai": path.join(WAI_DIR, "live-conformance", "browser", "int_motion.wai"),
"/vectors/int_video_sha256.json": path.join(WAI_DIR, "wai-rs", "tests", "vectors", "int_video", "sha256.json"),
});
const browser = await launch(dir);
const results = {};
try {
// Part 1: the test page. Every question put to the user agent's decoders and to Media
// Capabilities is recorded before the page runs, by a script the page does not control.
const ctx = await browser.newContext({ colorScheme: "dark" });
await ctx.addInitScript(() => {
window.__uaAsked = [];
for (const k of ["VideoDecoder", "AudioDecoder"]) {
const C = window[k];
if (!C) continue;
const f = C.isConfigSupported.bind(C);
C.isConfigSupported = (cfg) => { window.__uaAsked.push(`${k}:${cfg && cfg.codec}`); return f(cfg); };
}
if (navigator.mediaCapabilities) {
const f = navigator.mediaCapabilities.decodingInfo.bind(navigator.mediaCapabilities);
navigator.mediaCapabilities.decodingInfo = (q) => { window.__uaAsked.push(`decodingInfo:${(q.video || q.audio || {}).contentType}`); return f(q); };
}
});
const p = await ctx.newPage();
const logs = [];
p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
p.on("pageerror", (e) => logs.push(`[pageerror] ${e.stack || e}`));
await p.goto(`${srv.url}/binding/`, { waitUntil: "domcontentloaded", timeout: STEP });
const r = await until("the binding page runs to its end", STEP * 3, () => p.evaluate(() => window.bindingTest && window.bindingTest.done && window.bindingTest), 500);
fs.writeFileSync(path.join(dir, "binding.console.log"), logs.join("\n"));
if (r.errors.length) check(`${label}: the page ran without an error`, false, r.errors[0].split("\n").slice(0, 3).join(" | "));
results.page = r;
check(`${label}: live-conformance/browser through the page's wasm and its side table: every case gives the corpus's outcome`, r.corpus && r.corpus.fails.length === 0 && r.corpus.cases > 0,
`${r.corpus && r.corpus.cases} cases; ${r.corpus ? r.corpus.fails.slice(0, 4).join("; ") : "not run"}`);
// The wasm route.
const want = new Map();
for (const g of corpus.chain.groups) for (const o of g.objects) want.set(o.name, o);
const w = r.wasm_route || { objects: [] };
const verified = w.objects.filter((o) => o.outcome === "verified");
check(`${label}: on the wasm route each object is verified against its receipts, decoded, and every frame carries the binding the corpus states`,
verified.length === 3 && verified.every((o) => o.decoded === 5 && o.binding === want.get(o.name).binding && o.frames.every((f) => f.bound === o.binding)),
verified.map((o) => `${o.name}: ${o.decoded} frames, binding ${o.binding ? JSON.parse(o.binding).digest.slice(0, 12) : "none"}`).join("; "));
check(`${label}: each frame the wasm decoder output is the pinned reconstruction (its SHA-256 the published one) at tier decode-equivalence, and shows verified`,
verified.every((o) => o.frames.every((f) => f.digest_ok && f.format === "RGBX" && f.tier === "decode-equivalence" && f.state === "verified")),
verified.map((o) => o.frames.map((f) => `${f.digest_ok ? "=" : "≠"}${f.state}`).join(",")).join(" | "));
const changed = w.objects.find((o) => o.outcome === "refused");
check(`${label}: an object changed after it was receipted is refused and not decoded`, !!changed && changed.decoded === 0 && !changed.binding && !!changed.reason, changed ? `${changed.name}: ${changed.reason}` : "none");
check(`${label}: an object decoded with no receipt is decoded and left unbound (binding_unverified_reference)`, w.unverified && w.unverified.decoded === 5 && !w.unverified.binding && w.unverified.reason === "binding_unverified_reference", JSON.stringify(w.unverified));
// Through the frame's life.
const L = r.life || {};
check(`${label}: clone() carries the binding, and close() drops it`, L.clone && L.clone.kept && L.close && L.close.detached, JSON.stringify({ clone: L.clone, close: L.close }));
check(`${label}: a frame constructed from a bound frame carries no binding unless the page sets one`, L.construct && L.construct.carried === false, JSON.stringify(L.construct));
check(`${label}: a frame transferred to a worker with its binding beside it holds it there (and its clone there), comes back with it, and the source keeps none; one posted without it is unbound`,
L.transfer && L.transfer.in_worker && L.transfer.cloned_in_worker && L.transfer.back && L.transfer.source_detached && L.transfer.without, JSON.stringify(L.transfer));
check(`${label}: a re-encoded frame is a new object: decoded again under the bound frame's timestamp, it is unbound`,
L.reencode && typeof L.reencode === "object" && L.reencode.frames > 0 && L.reencode.same_timestamp && L.reencode.bound === false, JSON.stringify(L.reencode));
check(`${label}: copyTo() with no format gives the samples (hashed above); with one, other bytes`, L.copy_to && L.copy_to.samples > 0 && L.copy_to.conversion_differs !== false, JSON.stringify(L.copy_to));
// The user agent's own decoder, through the side table.
const n = r.native || {};
check(`${label}: every frame the user agent's VP9 decoder outputs is bound to the chunk it came from, by decoder and timestamp`, n.every_output_bound_to_its_chunk === true,
`${n.outputs} outputs of ${n.chunks} chunks, formats ${JSON.stringify(n.formats)}${n.errors && n.errors.length ? `, errors ${n.errors[0]}` : ""}`);
check(`${label}: two chunks given one decoder under one timestamp bind neither frame (binding_ambiguous)`, n.ambiguous && n.ambiguous.bound === 0 && n.ambiguous.refusals.some((x) => x.reason === "binding_ambiguous"), JSON.stringify(n.ambiguous));
// What the user agent does with an unregistered member: reported, not judged.
const md = r.metadata || {};
console.log(`${label}: VideoFrame metadata — contentBinding kept: ${md.member_kept}; buffer constructor ${JSON.stringify(md.buffer)}; frame constructor ${JSON.stringify(md.frame)}; clone ${JSON.stringify(md.clone)}; registered rtpTimestamp kept: ${JSON.stringify(md.registered)}; errors ${JSON.stringify(md.errors)}`);
check(`${label}: the metadata probe ran (what the user agent keeps of an unregistered member is reported above)`, md.errors && md.errors.length === 0 && typeof md.member_kept === "boolean", JSON.stringify(md.errors));
// The capability report from this user agent's answers.
const rep = r.report || { table: [], asked: [] };
const outside = ["av02.", "vvc1.", "iamf."];
const uaAsked = await p.evaluate(() => window.__uaAsked);
results.ua_asked = uaAsked;
check(`${label}: no codec string outside the WebCodecs Codec Registry is put to the user agent (AV2, VVC, IAMF), by the page's own count and by the interfaces' own`,
!rep.asked.some((c) => outside.some((x) => c.startsWith(x))) && !uaAsked.some((c) => outside.some((x) => c.includes(x))) && rep.asked.length > 0,
`asked ${rep.asked.join(", ")}; the interfaces saw ${uaAsked.length} questions`);
const by = Object.fromEntries(rep.table.map((e) => [e.name, e]));
check(`${label}: AV2, VVC and IAMF route nowhere in a page with no decoder for them, their UA leg codec_unregistered`,
["av2", "vvc", "iamf"].every((k) => by[k] && by[k].route === "none" && by[k].legs.ua === "codec_unregistered" && by[k].asked === false), ["av2", "vvc", "iamf"].map((k) => `${k}: ${by[k] && by[k].route} ${by[k] && by[k].legs.ua}`).join("; "));
check(`${label}: int_motion routes to the page's wasm decoder at decode-equivalence`, by.int_motion && by.int_motion.route === "wasm" && by.int_motion.tier === "decode-equivalence", JSON.stringify(by.int_motion && by.int_motion.legs));
check(`${label}: VP9 routes to the user agent's decoder, whose answer it is; powerEfficient is stated only for a route the user agent answered for`,
by.vp9 && by.vp9.route === "ua" && rep.table.every((e) => (e.route === "ua") === (typeof e.energy.power_efficient === "boolean") || (e.route === "ua" && e.energy.power_efficient === null)),
rep.table.map((e) => `${e.name} ${e.route} pe=${e.energy.power_efficient}`).join(", "));
// §6: a measured figure beside powerEfficient.
const m = await measureDecodes(p, label);
if (!m) {
skip(`${label}: a measured figure per decode beside powerEfficient`, "this host has no per-process energy counter");
} else if (!m.measurement) {
results.energy = m;
check(`${label}: a measured figure per decode beside powerEfficient`, false, m.why);
} else {
const entry = await p.evaluate((x) => window.bindingTest.energyEntry(x), m.measurement);
const none = await p.evaluate(() => window.bindingTest.energyEntry(null));
results.energy = { ...m, entry, unmetered: none };
const f = entry.energy.figure;
check(`${label}: the figure per decode measured on this host is reported beside the user agent's powerEfficient, which stays its answer`,
!!f && f.class === "OnChipCounter" && f.coverage === "Partial" && f.per_decode_micro === Math.floor(m.measurement.joules_micro / m.measurement.decodes) && entry.energy.power_efficient === (rep.ua["vp09.00.10.08"] || {}).power_efficient,
f ? `${f.per_decode_micro} µJ per decode (${f.decodes} decodes, ±${(f.relative_ppm / 1e4).toFixed(1)} % standard, ${f.class}, ${f.coverage}: CPU only) beside powerEfficient ${entry.energy.power_efficient}` : JSON.stringify(entry.energy));
check(`${label}: with no measurement the decode is unmetered, never 0 J`, none.energy.figure === null && none.energy.reason === null, JSON.stringify(none.energy));
}
await ctx.close();
} finally {
fs.writeFileSync(path.join(dir, "binding.json"), JSON.stringify(results, null, 1));
await browser.close();
await srv.close();
}
await bindingRoom(run, dir);
}
// Part 2: a room whose pages show the state of the frame on screen.
async function bindingRoom(run, dir) {
const label = "binding room";
const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob"], dir);
const relay = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: path.join(dir, "summaries.jsonl") }, dir, "relay");
const srv = await serve(WEB, { "/fixture.json": file });
const browser = await launch(dir);
const opts = { transport: "websocket", debug: "binding" };
const pages = [];
try {
const clock = await page(browser, roomUrl(srv.url, relay, file, { role: "clock", transport: "websocket" }), "clock", dir);
pages.push(clock);
await waitPhase(clock, "ready");
const [A, B] = [principalOf(fx, "alice"), principalOf(fx, "bob")];
const alice = await page(browser, roomUrl(srv.url, relay, file, { who: "alice", ...opts }), "alice", dir);
pages.push(alice);
await waitPhase(alice, "publishing");
await announce(clock, fx, "alice");
const bob = await page(browser, roomUrl(srv.url, relay, file, { who: "bob", ...opts }), "bob", dir);
pages.push(bob);
await waitPhase(bob, "publishing");
await announce(clock, fx, "bob");
for (const [p, other] of [[alice, B], [bob, A]]) {
const b = await until(`${p.name}'s tile of the other shows a bound, verified frame`, STEP * 2, async () => {
const s = await p.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const x = await p.evaluate(() => window.roomTest.binding());
const t = x.tiles.find((y) => y.principal === other);
return t && t.state === "verified" && x.dom && x.dom.some((d) => d.principal === other && d.state === "verified") && x;
}, 500);
fs.writeFileSync(path.join(dir, `${p.name}.binding.json`), JSON.stringify(b, null, 1));
const t = b.tiles.find((y) => y.principal === other);
check(`${label} ${p.name}: the frame on the other's tile is bound to an object this page verified, under that object's group receipt, and shows verified`,
t.state === "verified" && t.receipt_verified === true && /^[0-9a-f]{64}$/.test(t.digest) && /^[0-9a-f]{64}$/.test(t.receipt) && /^(linked|anchor at group \d+|history, group \d+, below the anchor)$/.test(t.chain),
`digest ${t.digest.slice(0, 16)}, receipt ${t.receipt.slice(0, 16)}, chain ${t.chain}, tier ${t.tier}, provenance ${t.provenance}`);
const routes = Object.values(b.routes);
check(`${label} ${p.name}: each track was routed to the user agent's decoder before it was decoded, its codec string asked, and nothing else`,
routes.length >= 2 && routes.every((e) => e.route === "ua") && routes.every((e) => b.asked.includes(e.codec)) && b.asked.every((c) => routes.some((e) => e.codec === c)),
routes.map((e) => `${e.capability} ${e.codec}: ${e.route}, tier ${e.tier}, powerEfficient ${e.energy && e.energy.power_efficient}`).join("; "));
check(`${label} ${p.name}: its decoder's frames are bound, and none ambiguously`, b.table.bound > 0 && b.table.refusals.length === 0, JSON.stringify(b.table));
check(`${label} ${p.name}: the page shows the state of the frame on screen behind its debug control`, !!b.dom && b.dom.length >= 1, b.dom ? b.dom.map((d) => d.text).join(" / ").slice(0, 200) : "hidden");
}
for (const p of pages) {
const s = await p.snap();
check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | "));
}
} finally {
for (const p of pages) { try { p.dump(); } catch { /* closed */ } }
const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]);
for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave()));
await bounded(browser.close());
await bounded(srv.close());
await bounded(relay.stop());
killStrays();
}
}
// ------------------------------------------------------------------------------- co-watch
// A watch room (WAI co-watch) kept by a room service, three viewers in one browser each.
async function scenarioCowatch(run) {
const label = "cowatch";
const dir = path.join(OUT, `run${run}-cowatch`);
fs.mkdirSync(dir, { recursive: true });
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the cowatch scenario");
const k = roomKeys(ROOM, dir);
const source = path.join(dir, "programme.wav");
const wav = writeWav(source, 120);
const relay = await startRelay(RELAY, { no_quic: true, auth: true, door: [], extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay");
const svc = await startRoomService(ROOM, {
keys: k.keys, relayWs: relay.wsUrl,
room: { watch_source: JSON.stringify(source), watch_duration_ms: wav.duration_ms, watch_control: 15, watch_window_ms: 5000 },
top: { public_prefix: JSON.stringify("/room-api") },
}, dir);
const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws });
const base = `${srv.url}/room/?service=/room-api`;
const browser = await launch(dir);
const pages = [];
const snap = async (p) => { const s = await p.snap(); if (s.errors.length) throw new Error(`${p.name}: ${s.errors[0]}`); return s; };
// Each viewer a page of its own: three players, each with its own media element and clock.
const enter = async (name) => {
const p = await page(browser, base, name, dir);
pages.push(p);
await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await p.click("#watch");
await waitPhase(p, "watching");
const s = await until(`${name} joins the watch room`, STEP, async () => { const x = await snap(p); return x.cowatch && x.cowatch.joined != null && x; }, 200);
return { p, s };
};
const cws = async () => Promise.all(pages.map(async (p) => [p.name, (await snap(p)).cowatch]));
// Every page holds `n` controls, is past the last one's instant (`after`, µs of room
// clock), and agrees with the room within the bound.
const together = async (what, n, playing, after = 0) => until(what, STEP, async () => {
const all = await cws();
const ok = all.every(([, c]) => c.controls >= n && c.room_now >= after && c.playing === playing && c.error_us != null && Math.abs(c.error_us) <= BOUND_US && !c.stopped);
return ok && all;
}, 250);
const BOUND_US = 250000;
try {
const A = await enter("ada");
const m = await markup(A.p);
check(`${label}: the co-watch section shows, outside the co-performance section, and so does the Log`, m.co_hidden && m.in_co.length === 0 && m.cowatch_shows && m.log_shows, JSON.stringify(m));
const B = await enter("ben");
check(`${label}: each page fetched the source and checked it by the content hash the room's policy names`, A.s.cowatch.verified_source && B.s.cowatch.verified_source, `${wav.bytes} bytes, ${wav.duration_ms} ms`);
check(`${label}: each page entered the room's co-watch log by a proof of its key, and its clock is estimated against the room's`, A.s.cowatch.joined > 0 && B.s.cowatch.joined > 0, `joined at ${A.s.cowatch.joined} and ${B.s.cowatch.joined} µs of room clock; offsets ${A.s.cowatch.offset_us} and ${B.s.cowatch.offset_us} µs`);
// Ada plays: every page applies it at its instant.
const playAt = await A.p.evaluate(() => window.roomTest.watchControl("play"));
const t1 = await together(`${label}: both play from Ada's control`, 1, true, playAt);
check(`${label}: a play, sent ahead of its instant, starts both players together`, true, t1.map(([n, c]) => `${n} ${(c.error_us / 1000).toFixed(1)} ms`).join(", ") + `; effective at ${playAt}`);
await sleep(4000);
// Ben pauses.
const pauseAt = await B.p.evaluate(() => window.roomTest.watchControl("pause"));
const t2 = await together(`${label}: both pause on Ben's control`, 2, false, pauseAt);
const pos2 = t2.map(([, c]) => c.current_time);
check(`${label}: a pause stops both players at one position`, Math.abs(pos2[0] - pos2[1]) * 1e6 <= BOUND_US, pos2.map((x) => x.toFixed(3)).join(" s, ") + " s");
await sleep(1500);
const againAt = await A.p.evaluate(() => window.roomTest.watchControl("play"));
await together(`${label}: both play again`, 3, true, againAt);
// Ben seeks to 60 s.
const seekAt = await B.p.evaluate(() => window.roomTest.watchControl("seek", 60e6));
const t4 = await together(`${label}: both go to 60 s on Ben's seek`, 4, true, seekAt);
check(`${label}: a seek takes every player to the position it names`, t4.every(([, c]) => c.current_time >= 59.5 && c.current_time < 75), t4.map(([n, c]) => `${n} at ${c.current_time.toFixed(2)} s`).join(", "));
await sleep(3000);
// Cleo joins late, and is brought to where the room is.
const C = await enter("cleo");
const t5 = await together(`${label}: the late joiner is brought to the room's position`, 4, true);
const late = t5.find(([n]) => n === "cleo")[1];
check(`${label}: a viewer that joins late reads the room's controls and seeks to the room's position`, late.seeks >= 1 && late.current_time >= 60, `cleo at ${late.current_time.toFixed(2)} s, ${(late.error_us / 1000).toFixed(1)} ms from the room, after ${late.seeks} seek(s)`);
// Two windows with all three present and nothing changing, then their skew statements.
const joinedC = C.s.cowatch.joined;
const target = Math.ceil((joinedC + 2_000_000) / 5_000_000) * 5_000_000 + 10_000_000;
const final = await until(`${label}: the clock states the windows after the late joiner settled`, STEP * 2, async () => {
const r = await fetch(`${srv.url}/room-api/watch/room`).then((x) => x.json());
const lw = r.skews && r.skews.length && r.skews[0];
return lw && lw.to >= target && r;
}, 500);
const lastWindow = final.skews;
const vs = lastWindow.flatMap((s) => s.viewers);
check(`${label}: the room clock's last window states all three viewers in sync within the bound`, vs.length === 3 && vs.every((v) => v.verdict === "in_sync" && v.pq <= BOUND_US && v.counted > 0 && v.counted === v.due),
vs.map((v) => `${v.principal.slice(0, 8)} ${v.verdict} p95 ${(v.pq / 1000).toFixed(1)} ms (${v.counted}/${v.due})`).join(", "));
check(`${label}: the service carried every event the pages posted`, Object.keys(final.refused || {}).length === 0, JSON.stringify(final.refused));
const shown = await until(`${label}: each page shows the room in sync`, STEP, async () => {
const all = await cws();
return all.every(([, c]) => c.last_window && c.last_window.to >= target && c.dom && /in sync/.test(c.dom)) && all;
}, 500);
check(`${label}: each page shows its own offset and the room's verdicts`, shown.every(([, c]) => /^You: [+−]\d+ ms from the room · Room: all 3 in sync within 250 ms$/.test(c.dom)), shown.map(([n, c]) => `${n}: "${c.dom}"`).join("; "));
const all = await cws();
check(`${label}: no page refused a record of the log or had an event refused`, all.every(([, c]) => Object.keys(c.refused).length === 0 && Object.keys(c.post_refused).length === 0 && !c.stopped),
all.map(([n, c]) => `${n}: ${c.statements} statements, ${c.posted} carried, ${c.nudges} nudged, ${c.seeks} seeks`).join("; "));
// The room as the service carried it, verified from the extension's text alone.
const roomFile = path.join(dir, "watch-room.json");
fs.writeFileSync(roomFile, JSON.stringify(final));
const verifier = path.resolve(WEB, "../../../../../wai/live-conformance/co_watch_verify.py");
const v = JSON.parse(execFileSync("python3", ["-I", verifier, "--room", roomFile], { maxBuffer: 64 << 20 }).toString());
const checked = v.skews || [];
const tail = checked.filter((x) => x.from >= target - 10_000_000 && x.viewers !== "unchecked");
check(`${label}: the room verifies by co_watch_verify.py, every skew statement recomputed, the controls applied in their order`,
v.outcome === "accept" && checked.length > 0 && checked.every((x) => x.viewers !== "unchecked") && v.controls.length === 4 && tail.length > 0 && tail.every((x) => x.viewers.every((y) => y.verdict === "in_sync")),
`${v.outcome}${v.reason ? ` ${v.reason} at ${JSON.stringify(v.at)}` : ""}: ${checked.length} skew statements, ${v.presentations ? v.presentations.length : 0} presentation statements, ${v.controls ? v.controls.length : 0} controls`);
} finally {
for (const p of pages) p.dump();
await browser.close().catch(() => {});
await srv.close();
await svc.stop();
await relay.stop();
killStrays();
}
}
// ------------------------------------------------------------------------------------- main
let passedRuns = 0;
// `--between <s>`: a pause before each run after the first. Against a deployment, the room
// service gives one client address a few grants at once and then one every so often, and
// each run takes four (three publishers and a viewer).
const BETWEEN = Number(args.between || 0);
for (let run = 1; run <= RUNS; run++) {
if (run > 1 && BETWEEN) await sleep(BETWEEN * 1000);
const before = failed;
const t = Date.now();
console.log(`== run ${run} of ${RUNS}`);
for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["dvr", scenarioDvr], ["service", scenarioService], ["e2ee", scenarioE2ee], ["age", scenarioAge], ["co", scenarioCo], ["ageco", scenarioAgeCo], ["binding", scenarioBinding], ["cowatch", scenarioCowatch], ["origin", scenarioOrigin]]) {
if (!ONLY.includes(name)) continue;
try {
await f(run);
} catch (e) {
check(`${name}: the scenario ran to its end`, false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | "));
}
}
if (failed === before) passedRuns++;
console.log(`== run ${run}: ${failed === before ? "passed" : "failed"} in ${((Date.now() - t) / 1000).toFixed(1)} s`);
}
fs.writeFileSync(path.join(OUT, "results.json"), JSON.stringify({ checks: results, skipped }, null, 1));
console.log(`room e2e: ${passedRuns} of ${RUNS} runs passed; ${results.filter((r) => r.ok).length} of ${results.length} checks ok${skipped.length ? `; ${skipped.length} step(s) skipped: ${[...new Set(skipped.map((x) => x.name))].join(", ")}` : ""}; artifacts in ${OUT}`);
killStrays();
process.exit(failed ? 1 : 0);