// 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 --wai --web // --corpus [--browser ] // [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,binding] [--runs N] // [--between ] [--out ] [--room ] [--base ] [--seek on|off] // [--wai-dir ] // // 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. // 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. // // With `--base ` the service scenario runs against a deployment (the page at // /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: of 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 } 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" : ""},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()}`); }); 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; } 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 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 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 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`); 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 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 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(); } } // -------------------------------------------------------------------------- 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 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(); } } // ------------------------------------------------------------------------------------- main let passedRuns = 0; // `--between `: 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], ["binding", scenarioBinding]]) { 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);