// 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,webtransport23,websocket23,goaway,dvr,service,e2ee,age,co,ageco,binding,cowatch, // source,stitched,injector,origin,termination,remote,world,privateworld,captioner] // [--runs N] // [--between ] [--out ] [--room ] [--base ] [--seek on|off] // [--wai-dir ] [--agent ] // // Scenarios: // compose live-conformance/compose run in the page: every layout, mix, descriptor // and composition case through wai-live-web's wasm build, and wai-rs's own // corpus check, in the browser; // webtransport two participants and the room's clock over WebTransport: each publishes // its camera and microphone and receives the other's, frames decode, every // received object verifies against its receipt (and the relay's door checks // each one), the composed canvas's tiles equal the host's layout for the // same descriptor and set and show video, the mix reaches the AudioWorklet; // a caption shows with its origin label; a third participant joins late and // gets the earlier groups by FETCH, each verified; // websocket the same, with the WebSocket binding forced on every page; // goaway the relay is shut down with GOAWAY naming a second relay: every page // moves there, closes its old session itself before the timeout, and media // flows again; // dvr a relay keeping a DVR window (WAI live-recording §8) with a cache too // small for the window: a participant seeks back eight seconds into the // other's tracks from its page's control; every object fetched from the // window verifies against its receipt, the video plays from its first key // frame in the page's own view, the page returns to live and live video goes // on decoding; the relay's summaries show the objects came from the window; // service a room service (joule-moq-room) keeps the room, as in production: each // page makes its own key and enters from its buttons, the service grants and // its clock authors the lifecycle, the relay (WebSocket only, behind a // same-origin proxy as behind the production front) honours the service's // grants by its authority's key. Two participants publish and see each // other, every object verifies, the layout equals the host's, a machine and a // typed human caption show their labels, a late joiner gets earlier groups, a // participant seeks back into the other's tracks from the relay's DVR window // with the dvr scenario's checks (the relay here keeps one; `--seek off`, or a // deployment unless `--seek on`, skips the step and reports it skipped), a // viewer watches, a grant with no proof or a proof by another key is refused // the session, a publisher past the room's places is told it is full, and the // room records a participant leaving; // e2ee an end-to-end encrypted room (WAI confidentiality) kept by a room service: // the page says so before anyone enters; each page checks the room's record, // keys through the service's log (its KeyPackage, the clock's proposals, a // GroupInfo, commits, Welcomes), seals every frame before it is signed and // opens every object before it is decoded; media flows both ways and decodes, // the page shows its epoch and roster, a subscriber outside the group (a // viewer) receives exactly the sealed bytes and opens nothing, every change a // relay could make to an object is refused with its reason, an object changed // on the wire is refused bad_tag, a late joiner opens nothing sealed before // its join, a member that stops publishing is removed and opens no later // epoch while still receiving, an object it sealed before is refused // sender_removed, and the relay's DVR store keeps nothing of the room. // age a room that admits by age (WAI age-assurance §5.6), its service configured // with the corpus's test issuers and rating the room 16, and a test issuer on // loopback over the published test keys: each page answers the service's // Privacy Pass challenges (RFC 9577) with a token it obtains by blind issuance // (RFC 9578 §6) and enters at the token's threshold. A performer at 18 rates its // own video 18 (§3.6); a viewer at 16 is served its audio, verified and decoded, // and the relay refuses it the video (below_threshold, §5.5), which the page shows // as withheld; a viewer at 18 is served both; a viewer whose token is of 13 is // refused by the service (below_threshold); a token spent once is refused when // presented again (token_replayed); the service's admission receipt (§7) // verifies under the room's clock and counts each admission, every cell under 50 // suppressed. // co a room with co-performance (WAI co-performance) kept by a room service, behind // a relay that holds the floor and the audience's inputs: a lead, an AI // participant (the page's scripted demo, synthetic media) and three audience // members. The lead starts turns from its page's controls; the performers' // pages show who holds the floor and send media only in their turns; one plays // out of turn and is marked by the room's clock, and under a later schedule that // refuses, its objects out of turn are held back by the relay; the AI // participant's lines show as upcoming before they play, one kept and one not; // an object late past its budget is marked; the audience votes and sends prompts // from the page's input box, the relay withholding each input §7.3 refuses with // its reason; each window is committed to and selected from by the clock, its // selection and credits shown; the lead asks for the fidelity statement; and the // room as a page holds it, with the clock's log of objects, verifies (accept) the // same in the page and on the host, every out-of-turn and late object marked. // ageco a room that admits by age and keeps co-performance: the room's clock proves // the room's keys and the relay serves its session as the room's own service // (age-assurance §5.5): it logs both performers' tracks, the lead's video rated // 18 among them, and marks an object out of turn; a viewer at 16 is still // refused that video. // origin the endpoints a link may name: a link naming a relay on another origin is // refused when the visitor joins, and nothing connects (no WebSocket, no // request, no grant, no camera); a room service on another origin, written // scheme-relative, behind the page's host as user information or as javascript:, // is refused and Join stays off; a relay whose GOAWAY names another origin is // refused, the session ends and the page says so. And in the service scenario, // every request and WebSocket of every page stays on the page's own origin. // binding the browser sink (WAI browser-sink): the page web/binding/ runs // live-conformance/browser through the page's wasm and side table, decodes a // wai.video.int_motion object on the wasm route after verifying it against // its receipts and binds every frame (each frame's SHA-256 the published // one), carries the binding through clone(), transfer to a worker, copyTo() // and re-encode as §8 has it, binds the user agent's own decoder's frames // through the side table, reports what the user agent keeps of an // unregistered VideoFrame metadata member, and builds a capability report // from the user agent's answers, putting no codec string outside the // registry to it; on a host with a per-process energy counter, a measured // figure per decode is reported beside powerEfficient. Then a room with // `debug=binding`: every tile's frame is bound to an object its page // verified, under that object's receipt, and the page shows it verified. // captioner an AI captioner (WAI live-rooms §4.4), wai-room-agent with its scripted test // recognizer, in a room kept by a room service with `[ai] captioners`: it enters // under its own grant and captions a participant's microphone; another page // shows each caption with the AI system's marker and the model's pin beside its // origin label, and names the captioner an AI participant; the page shows each // of the agent's finals in place of its partial, never the two at once // (timed-text §4.4). Then a page enters as a second captioner and signs // revisions in CTA-708 windows 0 and 1 and a 608 data channel (§4.7): bob shows // the three at once, one partial a window and each partial's unstable rest set // apart, and a final replacing its partial in its window alone; a page showing // the latest caption alone (the page before, planted) is caught; and carol, // joining inside the next minute, holds the captioner's group from its join set // (§4.6) and shows at every instant from its start what bob, who held the whole // stream, shows; // cowatch a watch room (WAI co-watch) kept by a room service: three viewers, each // page fetching the source and checking it by the content hash the room's // policy names, entering the room's co-watch log by a proof of its key; one // plays, one pauses, one seeks, each control applied by every page at its // instant (the one the room carried, which moves only when a page the room // took for gone signs it again); the service refuses no event but the posts // of a page it took for gone, each followed by that page entering again; a // viewer joins late and is brought to the room's position; every // page states what it presents twice a slot, and the room clock's skew // statements measure them: in the last windows every viewer and every pair is // in sync within the policy's bound, as each page shows; and the room, read // back from the service, verifies by co_watch_verify.py, the corpus's second // implementation, every skew statement recomputed. // source the room's own source (WAI co-watch §15.7) kept by a room service, through a // relay whose door holds each publisher's media to its key and reads the // source's running receipts: a viewer's page verifies each object on its run (or // its group's receipts), and presents each frame at the instant its playout // timing states, every frame within the page's bound of it; it reports the // relay's HopTime and its one-way delay. The relay's WebSocket to the page is // then held for longer than the delay: what came late is reported late, and // nothing else; every object is on time, late, refused with its reason (only // before the first key frame) or in flight, none dropped without a word. // injector an injector's captions in a room with ads (WAI live-ads §14.8, cue-injection §2, // essence-contribution §10.6): the test holds the key the room's configuration // names an injector, takes its grant from the room service (meta, cue and caption, // no media) and hands it to a page that signs captions as the gateway's injector // does, each record anchored to a programme video object and so indexed by its // Location. Bob follows the injector's events by its grant's kinds, though it // publishes no media, and shows its CTA-708 windows and 608 data channel at once, // one partial each, a line that left replaced in its window alone (timed-text // §4.4, §4.7); carol, joining inside a minute, shows at every instant what bob // shows (§4.6). Planted and caught: a page that follows a principal with no media // only as the page before did, and a page showing the latest caption alone; // termination stream termination (WAI stream-termination, capability-constraints §4): // live-conformance/termination through the page's wasm build; then a room // whose receiving page runs in a browser driven over the DevTools protocol // alone, so that its tab can really go to the background. Its tab goes behind // another: after the grace the page signs a termination as hidden and ends its // subscription to the other's video, the relay sends no more of it while the // audio goes on, the log verifies on the host (wai-rs, and the Python verifier) // with the facts the page states, and a segment receipt replayed after the // termination is refused after_termination; in front again, the video is // subscribed under a new log. The panel shrinks below the video: the page // declares it again and its receipts record the match, the least rendition // served; the canvas is moved out of the viewport and the video is terminated // as occluded. // remote a remote desktop room (WAI remote-desktop) the room service keeps // (`[remote]`): the host agent (`--remote `) enters with an // invitation the room's authority signed, its screen a pattern source through // the platform's hardware encoder on macOS and the pure-function encoder // elsewhere. Alice's page makes its key and is granted control (the allow rule // lets everyone ask), decodes the screen with WebCodecs and shows what the host // captures, asks for the floor and is given it, clicks and types on the canvas, // and signs a latency record of each applied input's frame at the refresh that // showed it. She types a string, composes a text and pastes one: each goes // sealed to the host's sealing key, the host injects what was typed, and Bob's // page, a viewer's that follows her events, shows that she is typing and // receives no trace of what. Bob's own guard sends nothing, and an input sent // past it (as a modified page would) the host refuses, as it refuses a text // sent in the clear. The session the host records, and the one the service's // clock holds, each verify by remote_desktop_verify.py from the extension's // text, as a viewer verifies them and under the host's opening key. // world a room with a shared world (WAI live-worlds) kept by a room service, its pages // web/world/: two pages act and converge on one session hash through the relay, // every object verified against its owner's receipt; a page entering late // fetches each world track from group 0 and holds the same world, then acts; a // viewer reads it all and acts in nothing; the relay refuses a viewer's PUBLISH // of a world track and an actor's of another principal's; its door drops a // group receipted by another key, which no page takes; and a page entering // after an actor left still fetches that actor's track. // privateworld a private world (live-worlds §8, an end-to-end encrypted room): each page // keys through the service's log and seals every world object before its // receipt is signed; a member that joins later gets the earlier ops by the // others' carries in its epoch; members converge; a viewer outside the group // verifies receipts over ciphertext and opens nothing; a third member replays // the world from its start; a member that leaves opens nothing sealed after // the commit that removed it; the relay's DVR store keeps nothing of it. // // 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, spawn } from "node:child_process"; import { serve, serveSameOrigin, startRelay, roomKeys, startRoomService, makeFixture, hostFrame, hostCoVerify, browserArgs, memguardedBrowser, killStrays, tmpdir, until, sleep, RUN_ID, writeWav, rawBrowser } 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; // The remote desktop host agent (joule-moq-remote's wai-remote-host), for the remote scenario. const REMOTE = args.remote ? path.resolve(args.remote) : null; // The AI participants' binary (joule-moq-agent's wai-room-agent), for the captioner scenario. const AGENT = args.agent ? path.resolve(args.agent) : null; const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,webtransport23,websocket23,goaway,dvr${ROOM ? ",service,e2ee,age,co,ageco,cowatch,source,stitched,injector,origin,world,privateworld" : ""},binding,termination${REMOTE && ROOM ? ",remote" : ""}${AGENT && ROOM ? ",captioner" : ""}`)).split(","); // The wai/ directory: the browser sink's corpus and the int_video frame digests it points to. const WAI_DIR = args["wai-dir"] ? path.resolve(args["wai-dir"]) : (CORPUS ? path.resolve(path.dirname(CORPUS), "..", "..") : null); const RUNS = Number(args.runs || 1); // `--seek on|off`: the service scenario's seek back into the relay's DVR window. On by default // on loopback, where the relay this starts keeps a window; off by default against a // deployment (`--base`), whose relay may keep none, and then reported as skipped. const SEEK = (args.seek || (BASE ? "off" : "on")) === "on"; if (args.seek && !["on", "off"].includes(args.seek)) { console.error("room e2e: --seek is on or off"); process.exit(2); } const OUT = args.out ? path.resolve(args.out) : tmpdir("out"); fs.mkdirSync(OUT, { recursive: true }); const require = createRequire(path.join(process.env.PLAYWRIGHT_HOST || path.join(path.dirname(new URL(import.meta.url).pathname), "node_modules"), "x")); const { chromium } = require("playwright"); const STEP = 60000; // the bound on any one wait const results = []; let failed = 0; function check(name, ok, detail = "") { results.push({ name, ok, detail }); if (!ok) failed++; console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); } // A step not run, and why: listed apart, never counted as a check that passed. const skipped = []; function skip(name, why) { skipped.push({ name, why }); console.log(`skip ${name} — ${why}`); } process.on("unhandledRejection", (e) => { console.error("unhandled:", e && e.stack || e); killStrays(); process.exit(1); }); async function launch(dir) { const bin = args.browser ? path.resolve(args.browser) : null; const opts = { headless: true, args: browserArgs() }; let guarded = false; if (bin) { const g = memguardedBrowser(bin, dir); opts.executablePath = g.executable; guarded = g.guarded; } const b = await chromium.launch(opts); console.log(`browser: ${b.version()} (${bin || "the automation package's own"}${guarded ? ", under memguard" : ""})`); return b; } async function page(browser, url, name, dir) { const ctx = await browser.newContext({ permissions: ["camera", "microphone"], colorScheme: "dark" }); const p = await ctx.newPage(); const logs = []; p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); p.on("pageerror", (e) => logs.push(`[pageerror] ${e.stack || e}`)); p.on("response", (r) => { if (r.status() >= 400) logs.push(`[http ${r.status()}] ${r.url()}`); }); // Every request and WebSocket the page makes, for the checks of where it connects. p.requests = []; p.sockets = []; p.on("request", (r) => p.requests.push(r.url())); p.on("websocket", (w) => p.sockets.push(w.url())); await p.goto(url, { waitUntil: "domcontentloaded", timeout: STEP }); p.dump = () => fs.writeFileSync(path.join(dir, `${name}.console.log`), logs.join("\n")); p.snap = () => p.evaluate(() => window.roomTest && window.roomTest.snapshot && window.roomTest.snapshot()); p.name = name; return p; } // The requests and WebSockets of `p` that leave `origin`, whose relay is the WebSocket of the // same host (blob: and data: URLs stay in the page). function offOrigin(p, origin) { const own = new URL(origin); const wsOwn = `${own.protocol === "https:" ? "wss:" : "ws:"}//${own.host}/`; return [...p.requests, ...p.sockets].filter((u) => !/^(blob|data):/.test(u) && !u.startsWith(`${own.origin}/`) && !u.startsWith(wsOwn)); } async function waitPhase(p, phase) { return until(`${p.name} ${phase}`, STEP, async () => { const s = await p.snap(); if (s && s.errors && s.errors.length) throw new Error(`${p.name}: ${s.errors[0]}`); return s && s.phase === phase && s; }, 200); } // The live caption lines a page shows (web/room/captions.js), each with its window, its origin's // label, its text (and the part past its stable prefix) and the AI system's marker and model pin. function captionLines(p) { return p.evaluate(() => [...document.querySelectorAll("#caption .line")].map((l) => ({ window: l.closest(".window")?.dataset.window, final: l.dataset.final === "true", label: l.querySelector(".origin")?.textContent, text: l.querySelector(".text")?.textContent, rest: l.querySelector(".rest")?.textContent || "", ai: l.querySelector(".ai")?.textContent || null, model: l.querySelector(".model")?.title || null, }))); } // The line of `p` whose text is `text`, or null. async function captionLine(p, text) { return (await captionLines(p)).find((l) => l.text === text) || null; } // 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)); // The UI budget: every draw answers a new frame or a re-layout (the first answers the start). A // count, which a busy host can't flake; the latency beside it is reported, not held. const ui = snap.ui; check(`${label}: drawing spends only on what changed`, ui.draws <= ui.frames + ui.relayouts + 1, JSON.stringify(ui)); 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; } // `draft`: the MOQT draft every page offers (its `moqt` parameter); the relay bridges a -23 // session to its -22 model, and the scenario's checks hold at either. async function scenarioRoom(run, transport, draft = 22) { const label = draft === 22 ? transport : `${transport} at -${draft}`; const dir = path.join(OUT, `run${run}-${transport}${draft === 22 ? "" : draft}`); 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" } : {}), ...(draft === 22 ? {} : { moqt: String(draft) }) }; 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" }), ...(draft === 22 ? {} : { moqt: String(draft) }) }; 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}"`); check(`${label}: the session speaks draft -${draft}`, s0.transport.draft === draft && (s0.transport.protocol === "" || s0.transport.protocol === `moqt-${draft}`), `draft ${s0.transport.draft}, 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 captionLine(bob, "the machine heard this"); return c && dom && { 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 () => captionLine(alice, "typed by a person")); 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. // The relay reads the page's SETUP before it sends its own, and says in its own that it // refuses (GRANT_REFUSED, live-rooms §10.5.2). Every page's first session was so given a // challenge, and its second admitted with its proof over it. const bare = await alice.evaluate(() => window.roomTest.tryBareGrant()); check(`${label}: a grant presented without its proof is refused the session`, bare.refused === "pop_missing", JSON.stringify(bare).slice(0, 200)); const refusals = relay.lines.filter((l) => l.includes("without a proof that passes")); check(`${label}: the relay refused no session whose grant was proved`, refusals.length === 0, `${refusals.length} refused: ${(refusals[0] || "").replace(/^.*INFO /, "").slice(0, 200)}`); const admitted = await alice.evaluate(() => window.roomTest.state.transport.sessions_to_admit); check(`${label}: a page that held no challenge is given one and admitted on its second session`, admitted === 2, `${admitted}`); 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(); // The relay's summary of each session names the draft it spoke on the wire. 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 drafts = [...new Set(sums.map((x) => x.summary && x.summary.draft))]; check(`${label}: the relay's summaries name the draft each session spoke`, sums.length > 0 && drafts.length === 1 && drafts[0] === `moqt-${draft}`, `${sums.length} summaries: ${drafts.join(", ")}`); } } // ---------------------------------------------------------------------------------- goaway async function scenarioGoaway(run) { const label = "goaway"; const dir = path.join(OUT, `run${run}-goaway`); fs.mkdirSync(dir, { recursive: true }); const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob"], dir); const base = { auth: true, grants: fx.relay_grants, door: fx.door_rules }; const next = await startRelay(RELAY, { ...base, summary_out: path.join(dir, "summaries-b.jsonl") }, dir, "relay-b"); const first = await startRelay(RELAY, { ...base, summary_out: path.join(dir, "summaries-a.jsonl"), goaway_uri: next.wt, goaway_timeout_ms: 10000 }, dir, "relay-a"); first.hashes = [first.hash, next.hash]; const srv = await serve(WEB, { "/fixture.json": file }); const browser = await launch(dir); const pages = []; try { const clock = await page(browser, roomUrl(srv.url, first, file, { role: "clock" }), "clock", dir); pages.push(clock); await waitPhase(clock, "ready"); const [A, B] = [principalOf(fx, "alice"), principalOf(fx, "bob")]; const alice = await page(browser, roomUrl(srv.url, first, file, { who: "alice" }), "alice", dir); pages.push(alice); await waitPhase(alice, "publishing"); await announce(clock, fx, "alice"); const bob = await page(browser, roomUrl(srv.url, first, file, { who: "bob" }), "bob", dir); pages.push(bob); await waitPhase(bob, "publishing"); await announce(clock, fx, "bob"); for (const [p, other] of [[alice, B], [bob, A]]) { await until(`${p.name} receives the other before GOAWAY`, STEP * 2, async () => { const s = await p.snap(); return s.subs[other] && s.subs[other].video.decoded >= 15; }, 500); } const before = { alice: (await alice.snap()).subs[B].video.verified, bob: (await bob.snap()).subs[A].video.verified }; // Shut the first relay down: GOAWAY to every session, naming the second. first.proc.kill("SIGINT"); for (const p of [alice, bob, clock]) { const g = await until(`${p.name} moves after GOAWAY`, STEP, async () => { const s = await p.snap(); return s.goaways.length && s.goaways[0].moved && s.goaways[0]; }, 250); check(`${label}: ${p.name} reads GOAWAY with the new session URI and moves`, g.uri === next.wt, `URI ${g.uri}, timeout ${g.timeout_ms} ms, moved over ${g.moved.transport} in ${Math.round(g.moved.at - g.at)} ms`); } for (const [p, other, k] of [[alice, B, "alice"], [bob, A, "bob"]]) { const v = await until(`${p.name} receives the other through the second relay`, STEP * 2, async () => { const s = await p.snap(); const t = s.subs[other]; return t && t.video.verified >= before[k] + 15 && t.video.refused === 0 && t.video; }, 500); check(`${label}: ${p.name} receives and verifies media again after moving`, true, `${v.verified - before[k]} more verified video objects, ${v.refused} refused`); } const e = await first.stop(null, 30000); const stopped = first.lines.find((l) => /stopped; \d+ session\(s\) closed at the GOAWAY timeout/.test(l)) || ""; const n = Number((stopped.match(/stopped; (\d+)/) || [])[1]); check(`${label}: every page closed its old session itself before the GOAWAY timeout`, n === 0, `${stopped.trim()} (${JSON.stringify(e)})`); for (const p of pages) { const s = await p.snap(); fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1)); check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | ")); } } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } await browser.close().catch(() => {}); await srv.close(); if (first.proc.exitCode == null) await first.stop("SIGKILL"); await next.stop(); killStrays(); } } // --------------------------------------------------------------------------------- service // Where the page's sections are, as the browser parsed them: the Log, the binding table and the // co-watch section are never inside the co-performance section, which a room without // co-performance hides; and the Log shows. A page may keep the Log behind a disclosure a viewer // opens (the deployment's page keeps it in a closed `
`, its technical log): there it // shows once each closed `
` around it is opened by a click on its ``, outermost // first and each summary showing, as a viewer opens it; they are closed again after. What this // guards against is a Log no viewer can reach, as one inside the hidden `#co` was. async function markup(p) { return p.evaluate(() => { const co = document.getElementById("co"); const inside = (id) => { const e = document.getElementById(id); return !!(co && e && co.contains(e)); }; const visible = (e) => !!e && (e.checkVisibility ? e.checkVisibility() : e.getClientRects().length > 0); const shows = (id) => visible(document.getElementById(id)); const log = document.getElementById("log"); const closed = []; for (let d = log && log.parentElement; d; d = d.parentElement) if (d.tagName === "DETAILS" && !d.open) closed.unshift(d); let reached = true; for (const d of closed) { const summary = d.querySelector(":scope > summary"); if (!visible(summary)) { reached = false; break; } summary.click(); } const log_shows = reached && closed.every((d) => d.open) && visible(log); for (const d of closed) d.open = false; return { co_hidden: !!(co && co.hidden), in_co: ["log", "binding-debug", "cowatch"].filter(inside), log_shows, log_behind_disclosures: closed.length, cowatch_shows: shows("cowatch") }; }); } async function scenarioService(run) { const label = BASE ? "production" : "service"; const dir = path.join(OUT, `run${run}-${label}`); fs.mkdirSync(dir, { recursive: true }); let relay = null, svc = null, srv = null, base; if (BASE) { base = `${BASE}/room/`; } else { if (!ROOM) throw new Error("--room is needed for the service scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); // A DVR window over every track and a cache far smaller than the seconds sought back, as // the dvr scenario's relay keeps, so a page seeks back, out of the window, under the seek // power the service's grants carry (live-rooms §10.6). relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 }, cache_bytes: 128 * 1024, dvr: { path: path.join(dir, "dvr-store"), max_age_s: 900, max_bytes: 256 * 1024 * 1024 } }, dir, "relay"); svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 } }, dir); srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); base = `${srv.url}/room/?service=/room-api`; } const url = (extra = {}) => { const u = new URL(base); for (const [k, v] of Object.entries(extra)) u.searchParams.set(k, v); return u.toString(); }; const browser = await launch(dir); const pages = []; const enter = async (name, button, extra, phase) => { const p = await page(browser, url(extra), name, dir); pages.push(p); await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); await p.click(button); const s = await waitPhase(p, phase); return { p, principal: s.me.principal, room: s.room }; }; try { const A = await enter("alice", "#join", {}, "publishing"); const m = await markup(A.p); check(`${label}: in a room without co-performance, the Log, the binding table and the co-watch section are not inside the co-performance section, and the Log shows`, m.co_hidden && m.in_co.length === 0 && m.log_shows, JSON.stringify(m)); const B = await enter("bob", "#join", {}, "publishing"); const [alice, bob] = [A.p, B.p]; check(`${label}: both participants are in one room`, A.room === B.room, `${A.room.slice(0, 16)} / ${B.room.slice(0, 16)}`); const s0 = await alice.snap(); check(`${label}: the session runs over the WebSocket binding, with a grant the service issued for the page's own key`, s0.transport.kind === "websocket" && s0.grant.role === "publisher" && /^[0-9a-f]{64}$/.test(s0.grant.digest), `${s0.transport.kind}, grant ${s0.grant.digest.slice(0, 16)}, expires ${new Date(s0.grant.exp * 1000).toISOString()}`); for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) { await until(`${p.name} decodes video from the other`, STEP * 2, async () => { const s = await p.snap(); if (s.errors.length) throw new Error(s.errors[0]); const t = s.subs[other]; return t && t.video.decoded >= 30 && t.audio.decoded >= 50 && s.mixed.voiced_blocks > 0; }, 500); } for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) mediaChecks(`${label} ${p.name}`, await p.snap(), other, { minVideo: 30, minAudio: 50 }); const sa = await alice.snap(); check(`${label}: the mix reaches the AudioWorklet`, sa.mixed.voiced_blocks > 0 && sa.worklet.played > 0, `${sa.mixed.voiced_blocks} voiced blocks, ${sa.worklet.played} frames played`); const present = (s) => (s.roomState && s.roomState.present ? s.roomState.present.length : 0); // Laid out, and every tile showing video: a tile's first frame follows its layout. const shown = (s) => s.frame && s.frame.tiles.length === present(s) && Object.keys(s.drawn || {}).length === s.frame.tiles.length && s.samples.every((x) => x.principal === "" || JSON.stringify(x.rgb) !== "[16,18,24]"); await until(`${label} alice lays out the room`, STEP, async () => { const s = await alice.snap(); return present(s) >= 2 && shown(s); }, 500); const sl = await alice.snap(); await compareLayout(`${label} alice`, alice, null, dir, present(sl)); const tl = (await bob.snap()).timeline; const byRoom = tl.filter((e) => e.who === "room").map((e) => e.kind); check(`${label}: the room's clock, not a page, authors the lifecycle`, ["join", "publish"].every((k) => byRoom.includes(k)) && !tl.some((e) => ["join", "publish", "leave"].includes(e.kind) && e.who !== "room"), `${byRoom.length} lifecycle events by the room's clock`); // Captions: a machine caption with its model, and one typed by a person in the page. const model = "9d".repeat(32); await alice.evaluate((m) => window.roomTest.sendCaption({ text: "the machine heard this", origin: 2, model: m, lang: "en" }), model); const cap = await until(`${label} bob shows alice's caption`, STEP, async () => captionLine(bob, "the machine heard this")); 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 () => captionLine(alice, "typed by a person")); 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.refused === "pop_missing", JSON.stringify(bare).slice(0, 200)); const wrong = await alice.evaluate(() => window.roomTest.tryWrongKey()); check(`${label}: a grant presented with a proof by another key is refused the session`, wrong.refused === "pop_wrong_key", JSON.stringify(wrong).slice(0, 200)); if (!BASE) { // Past the room's places (three here), a publisher is told the room is full. const d = await page(browser, url(), "dave", dir); pages.push(d); await until("dave's page ready", STEP, () => d.evaluate(() => window.roomTest && window.roomTest.state.phase === "idle"), 200); await d.click("#join"); const full = await until("dave told the room is full", STEP, async () => { const s = await d.snap(); return s.phase === "full" && s; }, 200); const msg = await d.evaluate(() => document.getElementById("status").textContent); check(`${label}: a publisher past the room's places is told it is full, and may watch`, full.phase === "full" && /publishers/.test(msg) && await d.evaluate(() => !document.getElementById("watch").disabled), msg); } // Alice leaves: the room records it, and everyone re-lays out. await alice.click("#leave"); const left = await until(`${label} bob sees alice leave`, STEP, async () => { const s = await bob.snap(); return s.timeline.some((e) => e.kind === "leave" && e.who === "room") && s.frame && !s.frame.tiles.some((x) => x.principal === A.principal) && s; }, 500); check(`${label}: a participant leaving is recorded by the room, and the others re-lay out`, true, `${left.frame.tiles.length} tile(s) after the leave`); // The production page is served with `connect-src 'self' wss:// blob:`: every page // here connects to its own origin alone. const pageOrigin = new URL(base).origin; const away = pages.flatMap((x) => offOrigin(x, pageOrigin)); const nReq = pages.reduce((n, x) => n + x.requests.length, 0), nWs = pages.reduce((n, x) => n + x.sockets.length, 0); check(`${label}: every request and WebSocket of every page stays on the page's own origin`, away.length === 0 && nWs > 0, away.length ? away.slice(0, 3).join(" ") : `${nReq} requests, ${nWs} WebSockets, all on ${pageOrigin}`); for (const p of pages) { const s = await p.snap(); fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1)); check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | ")); } if (relay) { const refusedAtDoor = relay.lines.filter((l) => l.includes("refused at the door")).length; check(`${label}: the relay's door verified every media object and refused none`, refusedAtDoor === 0, `${refusedAtDoor} refused`); } } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } // Every page leaves, so the room it used is left as it was found. Each step is bounded. const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave())); await bounded(browser.close()); if (srv) await bounded(srv.close()); if (svc) await bounded(svc.stop()); if (relay) await bounded(relay.stop()); killStrays(); } if (relay && SEEK) { const w = servedFromWindow(path.join(dir, "summaries.jsonl")); check(`${label}: the relay served the seek back from its DVR window`, w.objects > 0, `${w.objects} objects from the window over ${w.lines} sessions`); } } // ------------------------------------------------------------------------------------- dvr // Seek back from a page's own control into `principal`'s tracks, as kept by a relay's DVR // window (WAI live-recording §8, live-rooms §10.6): the seconds back, then Seek back; the // window's video plays from its first key frame in a view of its own at the pace it was // captured, every object verified against its receipt; then the page is back to live and live // video goes on decoding. The dvr scenario (a fixture's room) and the service scenario (a room // service's, the seek power in its grants) hold a seek back to these same checks. const SEEK_SECONDS = 8; // An AI captioner (WAI live-rooms §4.4) in a room kept by a room service that admits one // (`[ai] captioners`): wai-room-agent enters through the service under its own grant (the role // `captioner`, `ai` its model's pin), hears a participant's microphone through the relay and // signs captions; another participant's page shows each with the AI system's marker and the // model's pin beside its origin label, names the captioner an AI participant in its timeline, and // verifies the room it holds. The recognizer is wai-room-agent's scripted test recognizer: what // is checked is what a page shows of an AI participant, not what was said. // // Then the page's live captions (web/room/captions.js, WAI timed-text §4.4, §4.6, §4.7). The // agent's captions are partials and finals with annotations, in no window: each final replaces its // partial. Windows come from a page that enters as a second captioner (the demo model's pin) and // signs, at the end of a minute, a partial in CTA-708 window 0 that a second partial replaces, a // final in the 608 data channel whose second ends before the minute does, and partials in window 1 // and the data channel: bob, whose page followed the captioner from its first record, shows the // three windows at once. The minute turns; a final replaces window 0's partial. Carol enters then: // her page holds the captioner's group from its first object, the timed-text join set the // captioner's track writes, and not the records before it, and must show at every instant from // the group's start what bob shows, the agent's captions among them, whose track writes the same // join sets. async function scenarioCaptioner(run) { const label = "captioner"; const dir = path.join(OUT, `run${run}-${label}`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM || !AGENT) throw new Error("--room and --agent are needed for the captioner scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay"); // Two captioners' places: the agent's and the captioning page's; and grants enough for the five // pages and the agent from one loopback address. const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 }, rate: { client_burst: 20 }, extra: "[ai]\ncaptioners = 2" }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const base = `${srv.url}/room/?service=/room-api`; const agentLog = fs.openSync(path.join(dir, "agent.log"), "w"); const agent = spawn(AGENT, ["caption", "--service", svc.url, "--relay", relay.wsUrl, "--scripted", "--step-ms", "500"], { stdio: ["ignore", agentLog, agentLog] }); const browser = await launch(dir); const pages = []; const open = async (name) => { const p = await page(browser, base, name, dir); pages.push(p); await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); return p; }; const enter = async (name) => { const p = await open(name); await p.click("#join"); const s = await waitPhase(p, "publishing"); return { p, principal: s.me.principal, room: s.room }; }; try { const A = await enter("alice"); const B = await enter("bob"); check(`${label}: both participants are in one room`, A.room === B.room, `${A.room.slice(0, 16)} / ${B.room.slice(0, 16)}`); // The captioner's caption, as bob's page shows it: its origin label, the AI system's marker // and the model's pin. const shown = await until(`${label} bob shows the captioner's caption`, STEP * 3, async () => (await captionLines(B.p)).find((l) => l.ai), 500); const s = await B.p.snap(); const c = s.captions.find((x) => x.ai); check(`${label}: an AI participant's caption shows the AI system's marker beside its origin label`, shown.ai === "AI system" && /Machine-generated/.test(shown.label), `${shown.ai} · ${shown.label}: "${shown.text}"`); check(`${label}: it shows the model's pin, the one its grant names and the caption states`, /^[0-9a-f]{64}$/.test(shown.model || "") && c && c.model === shown.model && c.ai_model === shown.model, `${(shown.model || "").slice(0, 16)}`); check(`${label}: the page names the captioner an AI participant`, c && /^AI participant /.test(c.who), c && c.who); const human = s.captions.filter((x) => !x.ai); check(`${label}: no caption of a person carries the marker`, human.every((x) => !x.ai), `${human.length} other captions`); const tl = s.timeline.filter((e) => e.kind === "caption" && /^AI participant /.test(e.who)); check(`${label}: the timeline lists the captioner's captions as an AI participant's`, tl.length > 0, `${tl.length} captions`); const v = s.roomState && s.roomState.verdict; check(`${label}: the room bob holds verifies, the captioner's records among them (live-rooms §9)`, v && v.outcome !== "refuse", JSON.stringify(v)); // The agent's finals, each in place of the partial it supersedes (timed-text §4.4). const agentP = await B.p.evaluate(() => (window.roomTest.state.grants.find((g) => g.attrs.some((a) => a[0] === "ai")) || {}).principal); const replaced = await until(`${label}: bob shows a final of the agent's after the partial it supersedes`, STEP * 2, async () => { const h = (await B.p.snap()).captionView.history; const partials = new Set(h.flatMap((e) => e.lines.filter((l) => !l.final && l.principal === agentP).map((l) => l.digest))); const f = h.flatMap((e) => e.lines).find((l) => l.final && l.principal === agentP && l.supersedes && partials.has(l.supersedes)); return f && { f, h }; }, 500); check(`${label}: a final of the agent's is shown in place of the partial it supersedes, its partial shown before it`, !!replaced.f, `"${replaced.f.text}"`); const together = (h) => h.filter((e) => e.lines.some((l) => l.supersedes && e.lines.some((m) => m.digest === l.supersedes))); check(`${label}: no revision was ever shown beside the one that supersedes it`, together(replaced.h).length === 0, `${replaced.h.length} sets of lines shown; ${JSON.stringify(together(replaced.h).slice(0, 1))}`); // A page as a second captioner, in windows (timed-text §4.7). const W = await open("windows"); await W.evaluate(() => window.roomTest.enter("captioner")); const ws = await waitPhase(W, "captioning"); const Wp = ws.me.principal; check(`${label}: a page enters as a captioner, an AI participant under the demo model's pin`, ws.grant && ws.grant.role === "captioner", JSON.stringify(ws.grant)); await until(`${label}: bob follows the captioning page's events`, STEP, async () => (await B.p.snap()).events_subscribed.includes(Wp), 250); const now = () => W.evaluate(() => window.roomTest.now()); const rev = (o) => W.evaluate((o) => window.roomTest.sendRevision(o), o); // Into a minute's last 16 s: its records are in one group, and the minute turns after them. await until(`${label}: the room clock in a minute's last 16 s`, 75000, async () => { const x = (await now()) % 60e6; return x >= 44e6 && x < 48e6; }, 200); const g = Math.floor((await now()) / 60e6) + 1; const W0 = { source: "cta-708", service: 1, window: 0 }, W1 = { source: "cta-708", service: 1, window: 1 }, CC1 = { source: "cta-608", service: 1 }; const a0 = await rev({ text: "Two", window: W0, anchor: "bottom-left", stable_cp: 0 }); await sleep(300); await rev({ text: "An earlier line.", final: true, window: CC1, duration_ms: 1000 }); await sleep(300); const a = await rev({ text: "Two windows", supersedes: a0, window: W0, anchor: "bottom-left", stable_cp: 3 }); await sleep(300); const b = await rev({ text: "shown at once", window: W1, anchor: "top-right", stable_cp: 5 }); await sleep(1200); await rev({ text: "and one of 608", window: CC1, stable_cp: 3 }); const want3 = { "cta-708/1/0": "Two windows", "cta-708/1/1": "shown at once", "cta-608/1/0": "and one of 608" }; const has3 = (L) => Object.entries(want3).every(([w, t]) => L.some((l) => l.window === w && l.text === t && !l.final)); const three = await until(`${label}: bob shows the three windows at once`, 15000, async () => { const L = await captionLines(B.p); return has3(L) && L; }, 200); const inW = (w) => three.filter((l) => l.window === w && l.label && /Machine-generated/.test(l.label)); check(`${label}: bob shows CTA-708 windows 0 and 1 and the 608 data channel at once, one partial each (timed-text §4.4, §4.7)`, Object.keys(want3).every((w) => inW(w).length === 1), JSON.stringify(three.filter((l) => l.window !== "none"))); const p0 = three.find((l) => l.window === "cta-708/1/0"); check(`${label}: a partial's text past its stable prefix is set apart, its prefix not`, p0.rest === " windows" && !three.some((l) => l.text === "Two"), JSON.stringify(p0)); // Planted: the page before, which showed the latest caption alone. await B.p.evaluate(() => window.roomTest.captionsPlant("latest")); const planted = await captionLines(B.p); await B.p.evaluate(() => window.roomTest.captionsPlant(null)); check(`${label}: planted: a page showing the latest caption alone does not show the three windows, and is caught`, !has3(planted) && planted.length <= 1, JSON.stringify(planted.map((l) => l.text))); // The minute turns; window 0's final replaces its partial there alone. await until(`${label}: the minute turns`, 30000, async () => (await now()) >= g * 60e6 + 1e6, 200); await rev({ text: "Two windows.", supersedes: a, final: true, window: W0, anchor: "bottom-left", duration_ms: 8000 }); const fin = await until(`${label}: bob shows window 0's final in place of its partial`, 15000, async () => { const L = await captionLines(B.p); return L.some((l) => l.window === "cta-708/1/0" && l.text === "Two windows." && l.final) && L; }, 200); check(`${label}: a final replaces its partial in its window, and the other windows' partials stay`, !fin.some((l) => l.text === "Two windows") && fin.some((l) => l.window === "cta-708/1/1" && l.text === "shown at once") && fin.some((l) => l.window === "cta-608/1/0" && l.text === "and one of 608"), JSON.stringify(fin.filter((l) => l.window !== "none").map((l) => [l.window, l.text, l.final]))); // Carol joins inside the minute. const C = await open("carol"); await C.click("#watch"); await waitPhase(C, "watching"); await until(`${label}: carol shows the captioning page's windows`, STEP, async () => { const L = await captionLines(C); return L.some((l) => l.window === "cta-708/1/1") && L; }, 250); await rev({ text: "shown at once, and kept", supersedes: b, window: W1, anchor: "top-right", stable_cp: 13 }); await sleep(5000); // Up to 3 s ago: every record of an instant before it has reached both pages. const end = (await now()) - 3e6; const xs = []; for (let x = g * 60e6; x <= end; x += 100e3) xs.push(x); const flat = (r) => r.map((e) => ({ at: e.at, lines: e.groups.flatMap((q) => q.lines.map((l) => ({ ...l, window: q.window }))).sort((p, q) => (p.principal < q.principal ? -1 : p.principal > q.principal ? 1 : p.seq - q.seq)) })); const [vb, vc] = await Promise.all([B.p.evaluate((xs) => window.roomTest.captionsAt(xs), xs), C.evaluate((xs) => window.roomTest.captionsAt(xs), xs)]); const fb = flat(vb), fc = flat(vc); const diff = fb.filter((e, i) => JSON.stringify(e) !== JSON.stringify(fc[i])); check(`${label}: carol, joining inside the minute, shows at every instant from its start what bob, holding the whole stream, shows (timed-text §4.6)`, xs.length >= 30 && diff.length === 0, `${xs.length} instants from ${(g * 60).toFixed(0)} s, ${diff.length} differ${diff.length ? `: at ${diff[0].at} bob ${JSON.stringify(diff[0].lines.map((l) => l.text))}, carol ${JSON.stringify(fc[fb.indexOf(diff[0])].lines.map((l) => l.text))}` : ""}`); const atStart = fc[0].lines.filter((l) => l.principal === Wp).map((l) => [l.window, l.text]); check(`${label}: at the minute's start carol shows the three partials she holds from the join set alone, not the partial replaced nor the final whose second was over`, JSON.stringify(atStart.sort()) === JSON.stringify([["cta-608/1/0", "and one of 608"], ["cta-708/1/0", "Two windows"], ["cta-708/1/1", "shown at once"]]), JSON.stringify(atStart)); const sc = await C.snap(); const sb = await B.p.snap(); // What each page holds of the captioning page's captions: carol, the join set's and those after // it; bob, every one. const mine = (x) => x.captions.filter((q) => q.who === `AI participant ${Wp.slice(0, 4)}`).map((q) => q.text); const [hb, hc] = [mine(sb), mine(sc)]; check(`${label}: carol holds the captioning page's captions from the join set on, not the partial replaced nor the final over before the minute; bob holds every one`, ["Two", "An earlier line."].every((t) => hb.includes(t) && !hc.includes(t)) && ["Two windows", "shown at once", "and one of 608", "Two windows.", "shown at once, and kept"].every((t) => hb.includes(t) && hc.includes(t)), JSON.stringify({ bob: hb, carol: hc })); check(`${label}: no page refused a caption stream`, [sb, sc].every((x) => x.captionView.refused.length === 0), JSON.stringify([sb.captionView.refused, sc.captionView.refused])); fs.writeFileSync(path.join(dir, "captions.json"), JSON.stringify({ bob: sb.captionView, carol: sc.captionView, instants: xs.length }, null, 1)); } finally { for (const p of pages) p.dump(); agent.kill("SIGINT"); await sleep(500); await browser.close().catch(() => {}); srv.close(); await svc.stop(); await relay.stop(); } } 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`); } // ---------------------------------------------------------------------------- remote desktop // A GET of the room service with a principal's grant and a proof of possession by its key over the // service's challenge (live-rooms §10.5.3): the first answer, a 401, carries the challenge. The // proof is `WRP1`, version 1, the grant's digest, the key, the challenge and the context // `GET //`, signed with Ed25519 after the domain `wai:live-room-proof` and 0x01. async function provenGet(url, grantHex, seedHex) { const u = new URL(url); const grant = Buffer.from(grantHex, "hex"); const bearer = `Bearer ${grant.toString("base64url")}`; const first = await fetch(u, { headers: { authorization: bearer } }); const c = first.headers.get("wai-room-challenge"); if (first.status !== 401 || !c) return first; const key = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seedHex, "hex")]), format: "der", type: "pkcs8" }); const pub = crypto.createPublicKey(key).export({ format: "der", type: "spki" }).subarray(-32); const digest = crypto.createHash("sha256").update(grant.subarray(0, grant.length - 64)).digest(); const ctx = Buffer.from(`GET //${u.host}${u.pathname}`); const len = Buffer.alloc(2); len.writeUInt16LE(ctx.length); const body = Buffer.concat([Buffer.from("WRP1"), Buffer.from([1]), digest, pub, Buffer.from(c, "base64url"), len, ctx]); const sig = crypto.sign(null, Buffer.concat([Buffer.from("wai:live-room-proof\x01", "latin1"), body]), key); return fetch(u, { headers: { authorization: bearer, "wai-room-proof": Buffer.concat([body, sig]).toString("base64url") } }); } // A remote desktop room (WAI remote-desktop) the room service keeps (`[remote]`): the host agent // enters with an invitation the room's authority signed, publishes a pattern source's screen (on // macOS through the platform's hardware encoder, elsewhere the pure-function encoder) and keeps a // floor it gives on request. Alice's page makes its own key and asks to control, which the room's // allow rule grants; it decodes the screen with WebCodecs, asks for the floor, clicks and types on // the canvas, and signs a latency record of each frame an applied outcome names, presented at the // refresh that showed it. Bob's page is a viewer's: its own guard sends nothing, and an input it // is made to send past the guard, as a modified page would, the host refuses. The session the host // records, and the one the service's clock holds, each verify by the second implementation, // remote_desktop_verify.py, from the extension's text alone. async function scenarioRemote(run) { const label = "remote"; if (!REMOTE || !ROOM) throw new Error("--remote and --room are needed for the remote scenario"); const dir = path.join(OUT, `run${run}-remote`); fs.mkdirSync(dir, { recursive: true }); const k = roomKeys(ROOM, dir); const door = [{ prefix: ["*", "*", "screen"], 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") }, dir, "relay"); const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_present: 8 }, rate: { client_burst: 200, client_every_s: 1 }, extra: "[remote]\nwidth = 640\nheight = 360\nfps = 15\nallow = \"all\"\ncontrol = 11\nmax_age_ms = 3000\n", }, dir); // What alice types, composes and pastes: sealed to the host, never in the clear. const TYPED = "hunter2", COMPOSED = "s3cret text, é", PASTED = "paste-Secret-42"; const openingKey = path.join(dir, "opening.key"); const injectedFile = path.join(dir, "injected.json"); // The host's key, made by the agent (mode 0600), and an invitation the authority signed. const keyFile = path.join(dir, "host.key"); const hostKey = execFileSync(REMOTE, ["key", keyFile]).toString().trim(); const invitation = execFileSync(ROOM, ["invite", k.keys, "600"]).toString().trim(); const record = path.join(dir, "session.json"); const encoder = process.platform === "darwin" ? "platform" : "ipcm"; const hostLog = fs.openSync(path.join(dir, "host.log"), "w"); const host = spawn(REMOTE, ["run", "--room", svc.url, "--key", keyFile, "--invitation", invitation, "--relay", relay.wsUrl, "--source", "pattern", "--encoder", encoder, "--input", "record", "--floor", "floor", "--give-on-request", "--seconds", "240", "--record", record, "--opening-key", openingKey, "--record-injected", injectedFile, `--moq-web-run=${RUN_ID}`], { stdio: ["ignore", hostLog, hostLog] }); const exited = new Promise((r) => host.on("exit", (code, sig) => r({ code, sig }))); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const browser = await launch(dir); const pages = []; const base = `${srv.url}/room/?service=/room-api`; const remoteOf = (p) => p.evaluate(() => { const r = window.roomTest.remote; return r && { screen: r.screen && { width: r.screen.width, encoder: r.screen.encoder, hardware: r.screen.hardware, state: r.screen.state }, holder: r.holder, frames: r.frames.size, counts: r.counts, latency: r.lastLatency, me: r.me, joined: r.joined, control: r.control, panel: document.getElementById("remote-state").textContent }; }); try { await until("the host's screen statement reaches the room's clock", STEP, async () => { const r = await fetch(`${svc.url}/room`); const v = await r.json(); return v.remote && v.remote.screen && v; }, 250); const enter = async (name, button) => { const p = await page(browser, base, name, dir); pages.push(p); await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); await p.click(button); await waitPhase(p, "watching"); return p; }; const alice = await enter("alice", "#join"); const seen = await until("alice decodes the host's screen", STEP, async () => { const r = await remoteOf(alice); return r && r.joined && r.screen && r.frames >= 5 && r; }, 250); check(`${label}: alice's page decodes the host's screen, every frame verified first`, seen.frames >= 5, `${seen.frames} frames presented; encoder ${seen.screen.encoder} (hardware ${seen.screen.hardware})`); check(`${label}: alice's page made its key and holds a controller's grant the service issued for it`, seen.control === 11 && /lets you control/.test(seen.panel), `control ${seen.control}`); check(`${label}: the page shows what the host captures`, /live · 640×360 · a whole display/.test(seen.panel), seen.panel.slice(0, 200)); check(`${label}: the host states a sealing key, and the page says keys go sealed`, /Keys and text go sealed to the host/.test(seen.panel), seen.panel.slice(0, 260)); // Bob, a viewer, before alice types: his page follows every present principal's events. const bob = await enter("bob", "#watch"); const b0 = await until("bob has the screen statement", STEP, async () => { const r = await remoteOf(bob); return r && r.joined && r.screen && r; }, 250); check(`${label}: bob's page holds a viewer's grant`, b0.control === 0 && /lets you watch/.test(b0.panel), `control ${b0.control}`); await alice.click("#remote-request"); await until("the floor given to alice", STEP, async () => { const r = await remoteOf(alice); return r.holder === r.me; }, 200); // In view first: what the page shows above the canvas grows as principals join. await alice.locator("#remote-canvas").scrollIntoViewIfNeeded(); const box = await alice.locator("#remote-canvas").boundingBox(); await alice.mouse.move(box.x + box.width * 0.25, box.y + box.height * 0.5); await alice.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5); await alice.focus("#remote-canvas"); await alice.keyboard.press("KeyA"); const done = await until("alice's input applied and measured", STEP, async () => { const r = await remoteOf(alice); return r.counts.applied >= 2 && r.counts.latencies >= 2 && r; }, 250); check(`${label}: alice's pointer and keys are applied, and her latency records signed`, done.counts.applied >= 2 && done.counts.latencies >= 2, `${done.counts.sent} sent, ${done.counts.applied} applied, ${done.counts.latencies} latency records; last ${(done.latency / 1000).toFixed(1)} ms`); // Alice types a string, composes a text and pastes one; bob's page shows that she is typing. const before = done.counts.sent; await alice.keyboard.type(TYPED, { delay: 30 }); const typing = await until("bob's page shows alice typing", STEP, async () => { const r = await remoteOf(bob); return /is (typing|pasting)…/.test(r.panel) && r; }, 100); check(`${label}: bob's page shows that alice is typing`, /is typing…/.test(typing.panel), typing.panel.slice(-120)); await alice.evaluate(([composed, pasted]) => { const c = document.getElementById("remote-canvas"); c.dispatchEvent(new CompositionEvent("compositionend", { data: composed, bubbles: true })); const dt = new DataTransfer(); dt.setData("text/plain", pasted); c.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })); }, [COMPOSED, PASTED]); const typed = await until("alice's typing applied", STEP, async () => { const r = await remoteOf(alice); return r.counts.sent >= before + 3 && r.counts.applied === r.counts.sent && r; }, 250); check(`${label}: alice's typed, composed and pasted text are applied, sealed`, typed.counts.applied === typed.counts.sent, `${typed.counts.sent} sent, ${typed.counts.applied} applied`); // Past the page's guard, a text in the clear: the host refuses it (rule 8). await alice.evaluate(async () => { const r = window.roomTest.remote; const m = await import("./remote.js"); const i = { host: r.host, screen: r.screenDigest, actions: [{ dt_us: 0, type: "text", text: "sent in the clear" }] }; const res = await r.emit(1, m.inputPayload(i)); r.sent.set(res.digest, { t: res.t, input: i }); }); const unsealed = await until("alice's text in the clear refused", STEP, async () => { const r = await remoteOf(alice); return r.counts.refused.input_unsealed >= 1 && r; }, 250); check(`${label}: a text sent in the clear past the page's guard is refused input_unsealed`, unsealed.counts.refused.input_unsealed >= 1, JSON.stringify(unsealed.counts.refused)); // What bob's page received of alice: her sealed inputs, and no trace of what she typed. const got = await bob.evaluate(async (alicePk) => { const r = window.roomTest.remote; const m = await import("./remote.js"); const hexes = r.received.map((b) => m.hex(b)); const sealed = r.received.filter((b) => { const ev = m.readEvent(b); if (ev.principal !== alicePk) return false; const s = m.readStatement(ev.payload); return s && s.kind === "input" && s.actions.some((a) => a.type === "sealed"); }).length; return { hexes, sealed, panel: document.getElementById("remote-state").textContent }; }, seen.me); const needles = [TYPED, COMPOSED, PASTED, "sent in the clear"].map((t) => Buffer.from(t, "utf8").toString("hex")); // A key in the clear as §5.2 lays it out: type 4, usage 0x0b (KeyH) little-endian, down. needles.push("040b0001"); const found = needles.map((n) => got.hexes.some((h) => { for (let i = h.indexOf(n); i >= 0; i = h.indexOf(n, i + 1)) if (i % 2 === 0) return true; return false; })); check(`${label}: bob's page received alice's sealed inputs`, got.sealed >= 2, `${got.sealed} sealed input statements of ${got.hexes.length} records`); check(`${label}: bob's received records hold no trace of what alice typed, composed or pasted`, !found[0] && !found[1] && !found[2] && !found[4], JSON.stringify(found)); check(`${label}: and the search finds what was sent in the clear`, found[3], JSON.stringify(found)); check(`${label}: bob's page shows no text alice typed`, ![TYPED, COMPOSED, PASTED].some((t) => got.panel.includes(t)), got.panel.slice(-160)); // The page's own guard: a viewer's grant sends nothing. const guarded = await bob.evaluate(async () => { const r = window.roomTest.remote; const before = r.counts.sent; r.batch.push({ type: "move", x: 5, y: 5, at: 0 }); await r.flush(); return { sent: r.counts.sent - before, unsent: r.counts.unsent }; }); check(`${label}: a viewer's page sends no input`, guarded.sent === 0 && guarded.unsent >= 1, JSON.stringify(guarded)); // Past the page's guard, as a modified page would send it: the host decides. await bob.evaluate(async () => { const r = window.roomTest.remote; const m = await import("./remote.js"); const i = { host: r.host, screen: r.screenDigest, actions: [{ dt_us: 0, type: "move", x: 5, y: 5 }] }; const res = await r.emit(1, m.inputPayload(i)); r.sent.set(res.digest, { t: res.t, input: i }); }); const refused = await until("bob's input refused", STEP, async () => { const r = await remoteOf(bob); return r.counts.refused.input_not_permitted >= 1 && r; }, 250); check(`${label}: an input with no control power is refused by the host`, refused.counts.refused.input_not_permitted >= 1, JSON.stringify(refused.counts.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(" | ")); } await sleep(1000); } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } await browser.close().catch(() => {}); host.kill("SIGINT"); const e = await Promise.race([exited, sleep(20000).then(() => ({ timeout: true }))]); if (e.timeout) host.kill("SIGKILL"); await sleep(1000); // The session as the service's clock holds it, once the host has left. Its records are the // room's: with no grant the service refuses it, and the host's grant with its proof reads it. try { const bare = await fetch(`${svc.url}/remote/room`); check(`${label}: the session's records are refused without a grant of the room`, bare.status === 401, String(bare.status)); const room = await (await fetch(`${svc.url}/room`)).json(); const hostGrant = room.grants.find((g) => g.slice(74, 138) === hostKey); const seed = fs.readFileSync(keyFile, "utf8").trim(); const r = await provenGet(`${svc.url}/remote/room`, hostGrant, seed); check(`${label}: the host's grant and its proof read the session's records`, r.status === 200, String(r.status)); fs.writeFileSync(path.join(dir, "service-session.json"), JSON.stringify(await r.json())); fs.writeFileSync(path.join(dir, "service-state.json"), JSON.stringify(await (await provenGet(`${svc.url}/remote/state`, hostGrant, seed)).json(), null, 1)); } catch (err) { console.log(`the service's session: ${err.message}`); } await srv.close(); await svc.stop(); await relay.stop(); killStrays(); } // Each session, verified from the extension's text alone. const verifier = path.join(WAI_DIR, "live-conformance", "remote_desktop_verify.py"); for (const [who, file] of [["the host", record], ["the service's clock", path.join(dir, "service-session.json")]]) { check(`${label}: ${who} holds the session`, fs.existsSync(file), file); if (!fs.existsSync(file)) continue; const v = JSON.parse(execFileSync("python3", ["-I", verifier, "--room", file], { maxBuffer: 64 << 20 }).toString()); fs.writeFileSync(`${file}.verified.json`, JSON.stringify(v, null, 1)); check(`${label}: the session ${who} holds verifies by remote_desktop_verify.py`, v.outcome === "accept", v.reason ? `${v.reason} at ${JSON.stringify(v.at)}` : ""); const pageLatencies = (v.latencies || []).filter((l) => l.source === 1); check(`${label}: ${who}: the page's latency records are of the frame-timing interface, with their legs`, pageLatencies.length >= 2 && pageLatencies.every((l) => l.legs !== "unchecked" && l.legs.up + l.legs.host + l.legs.down === l.latency_us), pageLatencies.map((l) => `${(l.latency_us / 1000).toFixed(1)} ms`).join(", ")); const fates = (v.inputs || []).map((x) => x.fate.outcome === "refused" ? x.fate.reason : x.fate.outcome); check(`${label}: ${who}: the host's outcomes are the rules'`, fates.includes("applied") && fates.includes("input_not_permitted") && fates.includes("input_unsealed"), JSON.stringify(v.totals)); const raw = fs.readFileSync(file); check(`${label}: ${who}: the session's records hold no trace of what alice typed`, ![COMPOSED, PASTED].some((t) => raw.includes(Buffer.from(t, "utf8").toString("hex"))), ""); // At the opened level, under the host's opening key: every seal opens, and to what alice typed. if (!fs.existsSync(openingKey)) { check(`${label}: the host wrote its opening key`, false, openingKey); continue; } const key = fs.readFileSync(openingKey, "utf8").trim(); const o = JSON.parse(execFileSync("python3", ["-I", verifier, "--room", file, "--opening-key", key], { maxBuffer: 64 << 20 }).toString()); fs.writeFileSync(`${file}.opened.json`, JSON.stringify(o, null, 1)); const seals = (o.inputs || []).filter((x) => x.seal); const opened = seals.filter((x) => x.seal === "opened").map((x) => Buffer.from(x.opened, "hex")); check(`${label}: ${who}: the session verifies at the opened level, every seal opened`, o.outcome === "accept" && seals.length >= 3 && opened.length === seals.length, `${o.outcome}; ${opened.length} of ${seals.length} seals opened`); const has = (t) => opened.some((b) => b.includes(Buffer.from(t, "utf8"))); // KeyH, KeyU, KeyN, KeyT, KeyE, KeyR, Digit2 down: type 4, the usage little-endian, down 1. const keysOpened = [0x0b, 0x18, 0x11, 0x17, 0x08, 0x15, 0x1f].every((u) => opened.some((b) => b.includes(Buffer.from([4, u, 0, 1])))); check(`${label}: ${who}: the seals open to what alice typed, composed and pasted`, has(COMPOSED) && has(PASTED) && keysOpened, `composed ${has(COMPOSED)}, pasted ${has(PASTED)}, keys ${keysOpened}`); } // What the host's recorder injected, as digests: what alice typed, opened. const sha = (b) => crypto.createHash("sha256").update(b).digest("hex"); const inj = fs.existsSync(injectedFile) ? JSON.parse(fs.readFileSync(injectedFile, "utf8")) : null; const keysDown = Buffer.from([0x04, 0x0b, 0x18, 0x11, 0x17, 0x08, 0x15, 0x1f].flatMap((u) => [u, 0])); check(`${label}: the host injected the keys, the text and the clipboard text alice typed`, !!inj && inj.keys_sha256 === sha(keysDown) && inj.text_sha256 === sha(Buffer.from(COMPOSED, "utf8")) && inj.clipboard_sha256 === sha(Buffer.from(PASTED, "utf8")), JSON.stringify(inj && inj.counts)); } // ------------------------------------------------------------------------------------ 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(); } } // ---------------------------------------------------------------------------------- origin // The endpoints a link may name: a relay, a room service or a GOAWAY's new session on another // origin are refused, and the page connects to nothing there (./web/room/endpoint.js). async function scenarioOrigin(run) { const label = "origin"; const dir = path.join(OUT, `run${run}-origin`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM) throw new Error("--room is needed for the origin scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 } }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); // The fixture room of the GOAWAY step: its relay names a relay on another origin when it stops. const { fixture: fx, file } = makeFixture(WAI, ["alice"], dir); const away = "wss://relay.elsewhere.example/moq"; const moving = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: path.join(dir, "summaries-goaway.jsonl"), goaway_uri: away, goaway_timeout_ms: 10000 }, dir, "relay-goaway"); const fsrv = await serve(WEB, { "/fixture.json": file }); const browser = await launch(dir); const pages = []; const open = async (url, name) => { const p = await page(browser, url, name, dir); pages.push(p); return p; }; const statusOf = (p) => p.evaluate(() => document.getElementById("status").textContent); try { // A link that names a relay on another origin: the visitor presses Join, and the page // refuses the relay before it asks for the camera, a grant or a session. const foreign = "wss://relay.elsewhere.example/moq"; const A = await open(`${srv.url}/room/?service=/room-api&ws=${encodeURIComponent(foreign)}`, "foreign-relay"); await until(`${label} the page is ready`, STEP, () => A.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); await sleep(1000); const before = A.requests.length; await A.click("#join"); const sa = await until(`${label} the page refuses the link's relay`, STEP, async () => { const x = await A.snap(); return x.phase === "refused" && x; }, 200); const said = await statusOf(A); check(`${label}: a link naming a relay on another origin is refused when the visitor joins, and the page says so`, sa.refused.length === 1 && sa.refused[0].name === "ws" && sa.refused[0].value === foreign && /not this page's own/.test(said) && /connects to nothing/.test(said), said); await sleep(1500); const asked = A.requests.slice(before); check(`${label}: nothing connects: no WebSocket, no request after the press, no grant, no session`, A.sockets.length === 0 && asked.length === 0 && !A.requests.some((u) => u.includes("/grant")) && sa.transport == null && sa.grant == null, `${A.sockets.length} WebSockets, ${asked.length} requests after the press${asked.length ? `: ${asked.slice(0, 3).join(" ")}` : ""}`); const cam = await A.evaluate(() => !!(window.roomTest.state.capture && window.roomTest.state.capture.stream)); check(`${label}: the camera and microphone are not opened`, !cam, `capture: ${cam}`); // A link that names a room service on another origin, in the forms a link can hide it in: // the page takes none, and offers no Join. for (const [form, value] of [["scheme-relative", "//service.elsewhere.example/room-api"], ["with the page's host as user information", `http://${new URL(srv.url).host}@service.elsewhere.example/room-api`], ["javascript:", "javascript:alert(document.domain)"]]) { const B = await open(`${srv.url}/room/?service=${encodeURIComponent(value)}`, `foreign-service-${form.replace(/[^a-z]+/gi, "-").replace(/-$/, "")}`); const sb = await until(`${label} the page refuses the ${form} service`, STEP, async () => { const x = await B.snap(); return x && x.phase === "refused" && x; }, 200); const join = await B.evaluate(() => document.getElementById("join").disabled); await sleep(500); const off = offOrigin(B, srv.url); check(`${label}: a link naming a room service on another origin (${form}) is refused, Join stays off, and nothing leaves the page's origin`, sb.refused.length === 1 && sb.refused[0].name === "service" && join && off.length === 0 && B.sockets.length === 0, `${value} — ${sb.refused[0] && sb.refused[0].reason}; ${off.length} requests away`); } // A relay that stops with GOAWAY naming another origin: the page refuses to move there, // ends its session and says so. const u = new URL("/room/", fsrv.url); u.searchParams.set("fixture", "/fixture.json"); u.searchParams.set("wt", moving.wt); u.searchParams.set("ws", moving.wsUrl); u.searchParams.set("hashes", (moving.hashes || [moving.hash]).join(",")); const uc = new URL(u); uc.searchParams.set("role", "clock"); const clock = await open(uc.toString(), "goaway-clock"); await waitPhase(clock, "ready"); u.searchParams.set("who", "alice"); const C = await open(u.toString(), "goaway-elsewhere"); await waitPhase(C, "publishing"); moving.proc.kill("SIGINT"); const sc = await until(`${label} the page refuses the GOAWAY`, STEP, async () => { const x = await C.snap(); return x.goaways.length && x.goaways[0].refused && x.phase === "ended" && x; }, 250); await until(`${label} the clock's page refuses the GOAWAY too`, STEP, async () => { const x = await clock.snap(); return x.goaways.length && x.goaways[0].refused && x; }, 250); const saidC = await statusOf(C); check(`${label}: a GOAWAY naming another origin is refused, the session ends, and the page says so`, sc.goaways[0].uri === away && !sc.goaways[0].moved && /refused/.test(saidC), `${sc.goaways[0].uri}: ${sc.goaways[0].refused}; "${saidC.slice(0, 120)}"`); check(`${label}: after the refused GOAWAY nothing connects elsewhere`, offOrigin(C, fsrv.url).filter((x) => !x.startsWith("ws://127.0.0.1") && !x.startsWith("https://127.0.0.1")).length === 0 && !C.sockets.some((x) => x.includes("elsewhere")), `${C.sockets.length} WebSockets: ${C.sockets.join(" ")}`); const stopped = await moving.stop(null, 30000); const line = moving.lines.find((l) => /stopped; \d+ session\(s\) closed at the GOAWAY timeout/.test(l)) || ""; check(`${label}: the page closed its session itself, before the GOAWAY timeout`, Number((line.match(/stopped; (\d+)/) || [])[1]) === 0, `${line.trim()} (${JSON.stringify(stopped)})`); for (const p of pages) { const x = await p.snap(); fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1)); check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | ")); } } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); await bounded(browser.close()); await bounded(srv.close()); await bounded(fsrv.close()); await bounded(svc.stop()); await bounded(relay.stop()); if (moving.proc.exitCode == null) await bounded(moving.stop("SIGKILL")); killStrays(); } } // ------------------------------------------------------------ age assurance × co-performance // A room that admits by age and keeps co-performance. The room's clock follows and logs the // performers' rated tracks over a session of its own, which holds no grant and no token: it // proves the room's keys in its SETUP, and the relay serves it as the room's own service // (WAI age-assurance §5.5), while a viewer stays held to the serving rule. async function scenarioAgeCo(run) { const label = "ageco"; const dir = path.join(OUT, `run${run}-ageco`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM) throw new Error("--room is needed for the ageco scenario"); const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json")); const issuer = await startIssuer(keys); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: `${k.lines}\n[floor]\nroom_keys = ["${k.clock}"]\n`, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay"); const byIssuer = {}; for (const x of keys) (byIssuer[x.issuer] = byIssuer[x.issuer] || []).push(`{ threshold = ${x.threshold}, spki = "${x.spki}" }`); let age = "[age]\ntoken_window_s = 600\n"; for (const [name, ks] of Object.entries(byIssuer)) age += `[[age.issuers]]\nname = "${name}"\nkeys = [${ks.join(", ")}]\n`; age += `[[age.ratings]]\nscheme = "https://rating-a.example/scheme"\nvalue = "16"\nmin_age = 16\n`; const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 }, rate: { client_burst: 40, client_every_s: 1 }, co: { audience_input: 2, latency_budget_ms: 1500, lead: '"first"', ai: true, fidelity_every_s: 0, commit_after_ms: 1200, lead_ms: 300 }, top: { public_prefix: '"/room-api"' }, extra: age, }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const base = `${srv.url}/room/?service=/room-api&issuer_directory=${encodeURIComponent(issuer.url)}`; const url = (extra = {}) => { const u = new URL(base); for (const [kk, v] of Object.entries(extra)) u.searchParams.set(kk, v); return u.toString(); }; const browser = await launch(dir); const pages = []; const enter = async (name, button, extra, phase) => { const p = await page(browser, url(extra), name, dir); pages.push(p); await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); await p.click(button); const st = await until(`${name} ${phase}`, STEP, async () => { const x = await p.snap(); if (x && x.errors && x.errors.length) throw new Error(`${name}: ${x.errors[0]}`); return x && x.phase === phase && x; }, 200); return { p, principal: st.me && st.me.principal, s: st }; }; const co = (p) => p.evaluate(() => window.roomTest.coState()); try { // The lead at 18, its own video rated 18; the AI participant at 18. const A = await enter("lead", "#join", { age_threshold: "18", rate: "18", rate_scheme: "https://rating-a.example/scheme", rate_value: "18" }, "publishing"); const B = await enter("ai", "#join", { ai: "scripted", script: "off", age_threshold: "18" }, "publishing"); check(`${label}: both performers enter by a token of 18, in a room with co-performance`, A.s.age && A.s.age.admitted === 18 && B.s.age && B.s.age.admitted === 18 && !!(await co(A.p)), `admitted at ${A.s.age && A.s.age.admitted} and ${B.s.age && B.s.age.admitted}`); const proved = relay.lines.filter((l) => l.includes("the session is the room's own service")).length; check(`${label}: the room's clock proves the room's keys, and the relay takes its session for the room's own service`, proved >= 1, `${proved} room key(s) proved`); // A viewer at 16: held to the serving rule, the video its performer rates 18 withheld. const V16 = await enter("viewer16", "#watch", { age_threshold: "16" }, "watching"); const s16 = await until(`${label} the viewer at 16 decodes the audio, the video withheld`, STEP * 2, async () => { const x = await V16.p.snap(); if (x.errors.length) throw new Error(x.errors[0]); const t = x.subs[A.principal]; return t && t.audio.decoded >= 30 && (t.withheld || []).includes("video") && x; }, 500); const t16 = s16.subs[A.principal]; check(`${label}: a viewer at 16 is still held to the serving rule: the lead's audio served, its video rated 18 withheld`, t16.audio.verified >= 30 && t16.video.received === 0 && s16.age.withheld.some((w) => w.principal === A.principal && w.track === "video"), `${t16.audio.verified} audio verified, ${t16.video.received} video received`); // The clock logs both performers' objects, the lead's video rated 18 among them. const logged = await until(`${label} the clock logs every performer's tracks`, STEP * 2, async () => { const r = await (await fetch(`${srv.url}/room-api/co/objects?from=0`)).json(); const tracks = {}; for (const o of r.objects || []) (tracks[o.principal] = tracks[o.principal] || new Set()).add(o.track); const n = (P) => (tracks[P] ? tracks[P].size : 0); return n(A.principal) >= 2 && n(B.principal) >= 2 && { objects: (r.objects || []).length, lead: n(A.principal), ai: n(B.principal) }; }, 500); check(`${label}: the room's clock logs the objects of both performers' tracks, the lead's video rated 18 among them`, logged.lead >= 2 && logged.ai >= 2, `${logged.objects} objects; tracks: lead ${logged.lead}, AI ${logged.ai}`); // Turns from the lead's page; the other plays out of turn and the clock marks it, which it // can only do holding the rated objects. await A.p.fill("#co-turn-s", "5"); await A.p.fill("#co-turns-n", "6"); await A.p.selectOption("#co-mode", "mark"); await A.p.click("#co-start"); const sched = await until(`${label} both performers' pages hold the schedule`, STEP, async () => { const ss = await Promise.all([A, B].map((x) => co(x.p))); return ss.every((x) => x.schedules.length === 1) && ss[0].schedules[0]; }, 300); const holders = sched.segments[0].holders; const P2 = holders[0] === A.principal ? B : A; const seg = sched.segments[0]; const waitRoom = async (p, x) => { for (;;) { const nn = await p.evaluate(() => window.roomTest.now()); if (nn >= x) return nn; await sleep(Math.min(500, (x - nn) / 1000)); } }; const before = (await co(A.p)).marks.out_of_turn; await waitRoom(A.p, seg.start + 2_000_000); await P2.p.evaluate(() => window.roomTest.coPlayOutOfTurn(500)); const marked = await until(`${label} an object out of turn is marked`, STEP, async () => { const c = await co(A.p); return c.marks.out_of_turn > before && c.marks; }, 300); check(`${label}: a performer playing out of turn in a rated room is marked by the room's clock`, marked.out_of_turn > 0, `${marked.out_of_turn} out-of-turn mark(s)`); const below = relay.lines.filter((l) => l.includes("below_threshold")).length; check(`${label}: the relay refused below the threshold only where the rule holds`, below >= 1, `${below} refusal(s) logged`); for (const p of pages) { const x = await p.snap(); fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1)); check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | ")); } } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave())); await bounded(browser.close()); await bounded(srv.close()); await bounded(svc.stop()); await bounded(relay.stop()); await bounded(issuer.close()); killStrays(); } } // -------------------------------------------------------------------------- co-performance async function scenarioCo(run) { const label = "co"; const dir = path.join(OUT, `run${run}-co`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM) throw new Error("--room 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); // A refusal at an object names it, its arrival and the turn boundary or schedule `from` // nearest it: one a hop past an edge is the clock's arrival trailing the relay's (§8, §12). const refusedAt = async (r) => { const o = Array.isArray(r.at) && r.at[0] === "object" ? objects[r.at[1]] : null; if (!o) return `${r.reason} at ${JSON.stringify(r.at)}`; const ss = (await co(V[0].p)).schedules; const edges = ss.flatMap((s) => [s.from, ...s.segments.flatMap((g) => Array.from({ length: g.turns + 1 }, (_, i) => g.start + i * g.turn_us))]); const e = edges.reduce((b, x) => (Math.abs(o.arrival - x) < Math.abs(o.arrival - b) ? x : b), edges[0]); const who = o.principal === A.principal ? "the lead" : o.principal === B.principal ? "the AI participant" : o.principal.slice(0, 12); return `${r.reason} at ${JSON.stringify(r.at)}: ${who}'s object, arrived ${o.arrival} µs, ${o.arrival - e} µs from the edge at ${e}; the schedules from ${ss.map((s) => `${s.from} (${s.mode})`).join(", ")}`; }; 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` : await refusedAt(onHost)); check(`${label}: the page's verification of the room is the host's`, JSON.stringify(inPage) === JSON.stringify(onHost), inPage.outcome); check(`${label}: every object out of turn and every late object is marked`, out.length > 0 && out.every((f) => f.marks & 1) && lateF.length > 0 && lateF.every((f) => f.marks & 2), `${out.length} out of turn, ${lateF.length} late`); const intents = (onHost.intents || []).map((i) => i.outcome); check(`${label}: the verification's intents and fidelity are the page's and the clock's`, JSON.stringify(intents) === JSON.stringify(["kept", "diverged"]) && JSON.stringify((onHost.fidelity || []).map((f) => [f.principal, f.kept, f.announced])) === JSON.stringify(fState.entries.map((e) => [e.principal, e.kept, e.announced])), `${intents.join(", ")}; ${JSON.stringify(onHost.fidelity)}`); const wins = onHost.windows || []; check(`${label}: both windows' selections recompute from their commitments`, wins.length === 2 && wins.every((w) => w.picks != null && w.commit), wins.map((w) => `${w.rule}: ${JSON.stringify(w.picks)}`).join("; ")); for (const x of pages) { const sn = await x.snap(); fs.writeFileSync(path.join(dir, `${x.name}.snapshot.json`), JSON.stringify(sn, null, 1)); check(`${label}: ${x.name} reports no error`, sn.errors.length === 0, sn.errors.slice(0, 2).join(" | ")); } } finally { for (const x of pages) { try { x.dump(); } catch { /* closed */ } } const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); for (const x of pages) await bounded(x.evaluate(() => window.roomTest.leave && window.roomTest.leave())); await bounded(browser.close()); await bounded(srv.close()); await bounded(svc.stop()); await bounded(relay.stop()); killStrays(); } // The relay's summaries: each refusal counted by reason, and what it held back out of turn. const sums = fs.existsSync(path.join(dir, "summaries.jsonl")) ? fs.readFileSync(path.join(dir, "summaries.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []; const refused = {}; let outOfTurn = 0; for (const x of sums) { const p = x.summary.tally.publisher; for (const [kk, v] of Object.entries(p.co_refused || {})) refused[kk] = (refused[kk] || 0) + v; outOfTurn += p.refused_out_of_turn || 0; } check(`${label}: the relay's summaries count each refused input by reason`, ["input_over_limit", "window_unknown", "input_type_mismatch", "co_statement_malformed", "input_not_permitted"].every((kk) => refused[kk] >= 1), JSON.stringify(refused)); check(`${label}: under the schedule that refuses, the relay held back objects out of turn`, outOfTurn > 0, `${outOfTurn} held back`); } // ---------------------------------------------------------------------------------- binding const HERE_DIR = path.dirname(new URL(import.meta.url).pathname); // The CPU energy the browser's process tree has spent so far, in nanojoules, or null where // the host has no per-process counter (e2e/energy.py). function treeEnergy() { try { const r = JSON.parse(execFileSync("python3", ["-I", path.join(HERE_DIR, "energy.py"), RUN_ID], { encoding: "utf8" })); return r.nj != null ? r : null; } catch { return null; } } // browser-sink §6 from the host's side: the page decodes VP9 in windows, this process reads // the browser tree's counter around each, and around as many idle windows of the same // length; the figure is the marginal energy over the busy windows, with its standard // uncertainty from the spread of the windows' per-decode figures (GUM type A), rounded up. async function measureDecodes(p, label) { if (!treeEnergy()) return null; await p.evaluate(() => window.bindingTest.prepareBurst()); const W = 3000, N = 4; const idle = [], busy = []; await p.evaluate((ms) => window.bindingTest.burst(ms), 500); // warm the decoder up for (let i = 0; i < N; i++) { const a = treeEnergy(); const ta = process.hrtime.bigint(); await sleep(W); const b = treeEnergy(); const tb = process.hrtime.bigint(); idle.push({ nj: b.nj - a.nj, ns: Number(tb - ta) }); const c = treeEnergy(); const tc = process.hrtime.bigint(); const r = await p.evaluate((ms) => window.bindingTest.burst(ms), W); const d = treeEnergy(); const td = process.hrtime.bigint(); busy.push({ nj: d.nj - c.nj, ns: Number(td - tc), decodes: r.decodes }); } const idleRate = idle.reduce((k, x) => k + x.nj, 0) / idle.reduce((k, x) => k + x.ns, 0); // nJ per ns const per = busy.map((x) => (x.nj - idleRate * x.ns) / x.decodes); const mean = per.reduce((k, x) => k + x, 0) / per.length; const sd = Math.sqrt(per.reduce((k, x) => k + (x - mean) ** 2, 0) / (per.length - 1)); const marginalNj = busy.reduce((k, x) => k + (x.nj - idleRate * x.ns), 0); const decodes = busy.reduce((k, x) => k + x.decodes, 0); const windowUs = Math.round(busy.reduce((k, x) => k + x.ns, 0) / 1000); const raw = { idle, busy, per_decode_nj: per, idle_nj_per_s: idleRate * 1e9 }; if (!(marginalNj > 0) || !(mean > 0)) return { raw, measurement: null, why: "the busy windows drew no more than the idle ones" }; const measurement = { joules_micro: Math.round(marginalNj / 1000), decodes, window_us: windowUs, class: "OnChipCounter", relative_ppm: Math.ceil(((sd / Math.sqrt(per.length)) / mean) * 1e6), filtering: "Unknown", coverage: "Partial", fabrics: ["cpu"], }; console.log(`${label}: ${decodes} decodes over ${(windowUs / 1e6).toFixed(1)} s, ${(marginalNj / 1e6).toFixed(1)} mJ marginal over idle (${(idleRate * 1e9 / 1e6).toFixed(1)} mW idle)`); return { raw, measurement }; } async function scenarioBinding(run) { const label = "binding"; const dir = path.join(OUT, `run${run}-binding`); fs.mkdirSync(dir, { recursive: true }); if (!WAI_DIR) throw new Error("--wai-dir (or --corpus) is needed for the binding scenario"); const corpusFile = path.join(WAI_DIR, "live-conformance", "browser", "vectors.json"); const corpus = JSON.parse(fs.readFileSync(corpusFile, "utf8")); const srv = await serve(WEB, { "/vectors/browser.json": corpusFile, "/vectors/int_motion.wai": path.join(WAI_DIR, "live-conformance", "browser", "int_motion.wai"), "/vectors/int_video_sha256.json": path.join(WAI_DIR, "wai-rs", "tests", "vectors", "int_video", "sha256.json"), }); const browser = await launch(dir); const results = {}; try { // Part 1: the test page. Every question put to the user agent's decoders and to Media // Capabilities is recorded before the page runs, by a script the page does not control. const ctx = await browser.newContext({ colorScheme: "dark" }); await ctx.addInitScript(() => { window.__uaAsked = []; for (const k of ["VideoDecoder", "AudioDecoder"]) { const C = window[k]; if (!C) continue; const f = C.isConfigSupported.bind(C); C.isConfigSupported = (cfg) => { window.__uaAsked.push(`${k}:${cfg && cfg.codec}`); return f(cfg); }; } if (navigator.mediaCapabilities) { const f = navigator.mediaCapabilities.decodingInfo.bind(navigator.mediaCapabilities); navigator.mediaCapabilities.decodingInfo = (q) => { window.__uaAsked.push(`decodingInfo:${(q.video || q.audio || {}).contentType}`); return f(q); }; } }); const p = await ctx.newPage(); const logs = []; p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); p.on("pageerror", (e) => logs.push(`[pageerror] ${e.stack || e}`)); await p.goto(`${srv.url}/binding/`, { waitUntil: "domcontentloaded", timeout: STEP }); const r = await until("the binding page runs to its end", STEP * 3, () => p.evaluate(() => window.bindingTest && window.bindingTest.done && window.bindingTest), 500); fs.writeFileSync(path.join(dir, "binding.console.log"), logs.join("\n")); if (r.errors.length) check(`${label}: the page ran without an error`, false, r.errors[0].split("\n").slice(0, 3).join(" | ")); results.page = r; check(`${label}: live-conformance/browser through the page's wasm and its side table: every case gives the corpus's outcome`, r.corpus && r.corpus.fails.length === 0 && r.corpus.cases > 0, `${r.corpus && r.corpus.cases} cases; ${r.corpus ? r.corpus.fails.slice(0, 4).join("; ") : "not run"}`); // The wasm route. const want = new Map(); for (const g of corpus.chain.groups) for (const o of g.objects) want.set(o.name, o); const w = r.wasm_route || { objects: [] }; const verified = w.objects.filter((o) => o.outcome === "verified"); check(`${label}: on the wasm route each object is verified against its receipts, decoded, and every frame carries the binding the corpus states`, verified.length === 3 && verified.every((o) => o.decoded === 5 && o.binding === want.get(o.name).binding && o.frames.every((f) => f.bound === o.binding)), verified.map((o) => `${o.name}: ${o.decoded} frames, binding ${o.binding ? JSON.parse(o.binding).digest.slice(0, 12) : "none"}`).join("; ")); check(`${label}: each frame the wasm decoder output is the pinned reconstruction (its SHA-256 the published one) at tier decode-equivalence, and shows verified`, verified.every((o) => o.frames.every((f) => f.digest_ok && f.format === "RGBX" && f.tier === "decode-equivalence" && f.state === "verified")), verified.map((o) => o.frames.map((f) => `${f.digest_ok ? "=" : "≠"}${f.state}`).join(",")).join(" | ")); const changed = w.objects.find((o) => o.outcome === "refused"); check(`${label}: an object changed after it was receipted is refused and not decoded`, !!changed && changed.decoded === 0 && !changed.binding && !!changed.reason, changed ? `${changed.name}: ${changed.reason}` : "none"); check(`${label}: an object decoded with no receipt is decoded and left unbound (binding_unverified_reference)`, w.unverified && w.unverified.decoded === 5 && !w.unverified.binding && w.unverified.reason === "binding_unverified_reference", JSON.stringify(w.unverified)); // Through the frame's life. const L = r.life || {}; check(`${label}: clone() carries the binding, and close() drops it`, L.clone && L.clone.kept && L.close && L.close.detached, JSON.stringify({ clone: L.clone, close: L.close })); check(`${label}: a frame constructed from a bound frame carries no binding unless the page sets one`, L.construct && L.construct.carried === false, JSON.stringify(L.construct)); check(`${label}: a frame transferred to a worker with its binding beside it holds it there (and its clone there), comes back with it, and the source keeps none; one posted without it is unbound`, L.transfer && L.transfer.in_worker && L.transfer.cloned_in_worker && L.transfer.back && L.transfer.source_detached && L.transfer.without, JSON.stringify(L.transfer)); check(`${label}: a re-encoded frame is a new object: decoded again under the bound frame's timestamp, it is unbound`, L.reencode && typeof L.reencode === "object" && L.reencode.frames > 0 && L.reencode.same_timestamp && L.reencode.bound === false, JSON.stringify(L.reencode)); check(`${label}: copyTo() with no format gives the samples (hashed above); with one, other bytes`, L.copy_to && L.copy_to.samples > 0 && L.copy_to.conversion_differs !== false, JSON.stringify(L.copy_to)); // The user agent's own decoder, through the side table. const n = r.native || {}; check(`${label}: every frame the user agent's VP9 decoder outputs is bound to the chunk it came from, by decoder and timestamp`, n.every_output_bound_to_its_chunk === true, `${n.outputs} outputs of ${n.chunks} chunks, formats ${JSON.stringify(n.formats)}${n.errors && n.errors.length ? `, errors ${n.errors[0]}` : ""}`); check(`${label}: two chunks given one decoder under one timestamp bind neither frame (binding_ambiguous)`, n.ambiguous && n.ambiguous.bound === 0 && n.ambiguous.refusals.some((x) => x.reason === "binding_ambiguous"), JSON.stringify(n.ambiguous)); // What the user agent does with an unregistered member: reported, not judged. const md = r.metadata || {}; console.log(`${label}: VideoFrame metadata — contentBinding kept: ${md.member_kept}; buffer constructor ${JSON.stringify(md.buffer)}; frame constructor ${JSON.stringify(md.frame)}; clone ${JSON.stringify(md.clone)}; registered rtpTimestamp kept: ${JSON.stringify(md.registered)}; errors ${JSON.stringify(md.errors)}`); check(`${label}: the metadata probe ran (what the user agent keeps of an unregistered member is reported above)`, md.errors && md.errors.length === 0 && typeof md.member_kept === "boolean", JSON.stringify(md.errors)); // The capability report from this user agent's answers. const rep = r.report || { table: [], asked: [] }; const outside = ["av02.", "vvc1.", "iamf."]; const uaAsked = await p.evaluate(() => window.__uaAsked); results.ua_asked = uaAsked; check(`${label}: no codec string outside the WebCodecs Codec Registry is put to the user agent (AV2, VVC, IAMF), by the page's own count and by the interfaces' own`, !rep.asked.some((c) => outside.some((x) => c.startsWith(x))) && !uaAsked.some((c) => outside.some((x) => c.includes(x))) && rep.asked.length > 0, `asked ${rep.asked.join(", ")}; the interfaces saw ${uaAsked.length} questions`); const by = Object.fromEntries(rep.table.map((e) => [e.name, e])); check(`${label}: AV2, VVC and IAMF route nowhere in a page with no decoder for them, their UA leg codec_unregistered`, ["av2", "vvc", "iamf"].every((k) => by[k] && by[k].route === "none" && by[k].legs.ua === "codec_unregistered" && by[k].asked === false), ["av2", "vvc", "iamf"].map((k) => `${k}: ${by[k] && by[k].route} ${by[k] && by[k].legs.ua}`).join("; ")); check(`${label}: int_motion routes to the page's wasm decoder at decode-equivalence`, by.int_motion && by.int_motion.route === "wasm" && by.int_motion.tier === "decode-equivalence", JSON.stringify(by.int_motion && by.int_motion.legs)); check(`${label}: VP9 routes to the user agent's decoder, whose answer it is; powerEfficient is stated only for a route the user agent answered for`, by.vp9 && by.vp9.route === "ua" && rep.table.every((e) => (e.route === "ua") === (typeof e.energy.power_efficient === "boolean") || (e.route === "ua" && e.energy.power_efficient === null)), rep.table.map((e) => `${e.name} ${e.route} pe=${e.energy.power_efficient}`).join(", ")); // §6: a measured figure beside powerEfficient. const m = await measureDecodes(p, label); if (!m) { skip(`${label}: a measured figure per decode beside powerEfficient`, "this host has no per-process energy counter"); } else if (!m.measurement) { results.energy = m; check(`${label}: a measured figure per decode beside powerEfficient`, false, m.why); } else { const entry = await p.evaluate((x) => window.bindingTest.energyEntry(x), m.measurement); const none = await p.evaluate(() => window.bindingTest.energyEntry(null)); results.energy = { ...m, entry, unmetered: none }; const f = entry.energy.figure; check(`${label}: the figure per decode measured on this host is reported beside the user agent's powerEfficient, which stays its answer`, !!f && f.class === "OnChipCounter" && f.coverage === "Partial" && f.per_decode_micro === Math.floor(m.measurement.joules_micro / m.measurement.decodes) && entry.energy.power_efficient === (rep.ua["vp09.00.10.08"] || {}).power_efficient, f ? `${f.per_decode_micro} µJ per decode (${f.decodes} decodes, ±${(f.relative_ppm / 1e4).toFixed(1)} % standard, ${f.class}, ${f.coverage}: CPU only) beside powerEfficient ${entry.energy.power_efficient}` : JSON.stringify(entry.energy)); check(`${label}: with no measurement the decode is unmetered, never 0 J`, none.energy.figure === null && none.energy.reason === null, JSON.stringify(none.energy)); } await ctx.close(); } finally { fs.writeFileSync(path.join(dir, "binding.json"), JSON.stringify(results, null, 1)); await browser.close(); await srv.close(); } await bindingRoom(run, dir); } // Part 2: a room whose pages show the state of the frame on screen. async function bindingRoom(run, dir) { const label = "binding room"; const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob"], dir); const relay = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: path.join(dir, "summaries.jsonl") }, dir, "relay"); const srv = await serve(WEB, { "/fixture.json": file }); const browser = await launch(dir); const opts = { transport: "websocket", debug: "binding" }; const pages = []; try { const clock = await page(browser, roomUrl(srv.url, relay, file, { role: "clock", transport: "websocket" }), "clock", dir); pages.push(clock); await waitPhase(clock, "ready"); const [A, B] = [principalOf(fx, "alice"), principalOf(fx, "bob")]; const alice = await page(browser, roomUrl(srv.url, relay, file, { who: "alice", ...opts }), "alice", dir); pages.push(alice); await waitPhase(alice, "publishing"); await announce(clock, fx, "alice"); const bob = await page(browser, roomUrl(srv.url, relay, file, { who: "bob", ...opts }), "bob", dir); pages.push(bob); await waitPhase(bob, "publishing"); await announce(clock, fx, "bob"); for (const [p, other] of [[alice, B], [bob, A]]) { const b = await until(`${p.name}'s tile of the other shows a bound, verified frame`, STEP * 2, async () => { const s = await p.snap(); if (s.errors.length) throw new Error(s.errors[0]); const x = await p.evaluate(() => window.roomTest.binding()); const t = x.tiles.find((y) => y.principal === other); return t && t.state === "verified" && x.dom && x.dom.some((d) => d.principal === other && d.state === "verified") && x; }, 500); fs.writeFileSync(path.join(dir, `${p.name}.binding.json`), JSON.stringify(b, null, 1)); const t = b.tiles.find((y) => y.principal === other); check(`${label} ${p.name}: the frame on the other's tile is bound to an object this page verified, under that object's group receipt, and shows verified`, t.state === "verified" && t.receipt_verified === true && /^[0-9a-f]{64}$/.test(t.digest) && /^[0-9a-f]{64}$/.test(t.receipt) && /^(linked|anchor at group \d+|history, group \d+, below the anchor)$/.test(t.chain), `digest ${t.digest.slice(0, 16)}, receipt ${t.receipt.slice(0, 16)}, chain ${t.chain}, tier ${t.tier}, provenance ${t.provenance}`); const routes = Object.values(b.routes); check(`${label} ${p.name}: each track was routed to the user agent's decoder before it was decoded, its codec string asked, and nothing else`, routes.length >= 2 && routes.every((e) => e.route === "ua") && routes.every((e) => b.asked.includes(e.codec)) && b.asked.every((c) => routes.some((e) => e.codec === c)), routes.map((e) => `${e.capability} ${e.codec}: ${e.route}, tier ${e.tier}, powerEfficient ${e.energy && e.energy.power_efficient}`).join("; ")); check(`${label} ${p.name}: its decoder's frames are bound, and none ambiguously`, b.table.bound > 0 && b.table.refusals.length === 0, JSON.stringify(b.table)); check(`${label} ${p.name}: the page shows the state of the frame on screen behind its debug control`, !!b.dom && b.dom.length >= 1, b.dom ? b.dom.map((d) => d.text).join(" / ").slice(0, 200) : "hidden"); } for (const p of pages) { const s = await p.snap(); check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | ")); } } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave())); await bounded(browser.close()); await bounded(srv.close()); await bounded(relay.stop()); killStrays(); } } // ------------------------------------------------------------------------------- co-watch // A watch room (WAI co-watch) kept by a room service, three viewers in one browser each. async function scenarioCowatch(run) { const label = "cowatch"; const dir = path.join(OUT, `run${run}-cowatch`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM) throw new Error("--room is needed for the cowatch scenario"); const k = roomKeys(ROOM, dir); const source = path.join(dir, "programme.wav"); const wav = writeWav(source, 120); const relay = await startRelay(RELAY, { no_quic: true, auth: true, door: [], extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { watch_source: JSON.stringify(source), watch_duration_ms: wav.duration_ms, watch_control: 15, watch_window_ms: 5000 }, top: { public_prefix: JSON.stringify("/room-api") }, }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const base = `${srv.url}/room/?service=/room-api`; const browser = await launch(dir); const pages = []; const snap = async (p) => { const s = await p.snap(); if (s.errors.length) throw new Error(`${p.name}: ${s.errors[0]}`); return s; }; // Each viewer a page of its own: three players, each with its own media element and clock. const enter = async (name) => { const p = await page(browser, base, name, dir); pages.push(p); await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); await p.click("#watch"); await waitPhase(p, "watching"); const s = await until(`${name} joins the watch room`, STEP, async () => { const x = await snap(p); return x.cowatch && x.cowatch.joined != null && x; }, 200); return { p, s }; }; const cws = async () => Promise.all(pages.map(async (p) => [p.name, (await snap(p)).cowatch])); // Every page holds `n` controls, is past the last one's instant (`after`, µs of room // clock), and agrees with the room within the bound. const together = async (what, n, playing, after = 0) => until(what, STEP, async () => { const all = await cws(); const ok = all.every(([, c]) => c.controls >= n && c.room_now >= after && c.playing === playing && c.error_us != null && Math.abs(c.error_us) <= BOUND_US && !c.stopped); return ok && all; }, 250); const BOUND_US = 250000; // A control signed on `who`'s page, the n-th of the room's, and the instant the room carried // it at, read from the room's log. A page the room took for gone (a page on a loaded machine // can be silent past its limit) enters again and signs again what the room had not carried, // no earlier than its new join plus the lead, so that instant can be later than the one the // page first signed it for. Only that moves it: the room carries it at the instant signed // unless the page entered again between signing it and its being carried (`rejoins`, which // the page counts as it signs again; `gone` counts the refusal before it enters, so a page // already entering again when the control is signed would have counted it before). const control = async (who, n, name, arg = 0) => { const before = (await snap(who.p)).cowatch.rejoins; const signed = await who.p.evaluate(([x, y]) => window.roomTest.watchControl(x, y), [name, arg]); const at = await until(`${label}: ${who.p.name}'s ${name} carried`, STEP, async () => { const c = (await who.p.evaluate(() => window.roomTest.watchLog())).entries.filter((e) => e.kind === "control")[n - 1]; return c && c.principal === who.s.cowatch.principal && c.at; }, 250); const after = (await snap(who.p)).cowatch.rejoins; check(`${label}: ${who.p.name}'s ${name} is carried at the instant signed, unless its page entered again meanwhile`, at === signed || after > before, `signed for ${signed}, carried at ${at}; the page entered again ${after - before} time(s) meanwhile`); return { signed, at, moved: at !== signed ? ` (signed for ${signed}, signed again after its page entered again)` : "" }; }; try { const A = await enter("ada"); const m = await markup(A.p); check(`${label}: the co-watch section shows, outside the co-performance section, and so does the Log`, m.co_hidden && m.in_co.length === 0 && m.cowatch_shows && m.log_shows, JSON.stringify(m)); const B = await enter("ben"); check(`${label}: each page fetched the source and checked it by the content hash the room's policy names`, A.s.cowatch.verified_source && B.s.cowatch.verified_source, `${wav.bytes} bytes, ${wav.duration_ms} ms`); check(`${label}: each page entered the room's co-watch log by a proof of its key, and its clock is estimated against the room's`, A.s.cowatch.joined > 0 && B.s.cowatch.joined > 0, `joined at ${A.s.cowatch.joined} and ${B.s.cowatch.joined} µs of room clock; offsets ${A.s.cowatch.offset_us} and ${B.s.cowatch.offset_us} µs`); // Ada plays: every page applies it at its instant. const play = await control(A, 1, "play"); const t1 = await together(`${label}: both play from Ada's control`, 1, true, play.at); check(`${label}: a play, sent ahead of its instant, starts both players together`, true, t1.map(([n, c]) => `${n} ${(c.error_us / 1000).toFixed(1)} ms`).join(", ") + `; effective at ${play.at}${play.moved}`); await sleep(4000); // Ben pauses. const pause = await control(B, 2, "pause"); const t2 = await together(`${label}: both pause on Ben's control`, 2, false, pause.at); const pos2 = t2.map(([, c]) => c.current_time); check(`${label}: a pause stops both players at one position`, Math.abs(pos2[0] - pos2[1]) * 1e6 <= BOUND_US, pos2.map((x) => x.toFixed(3)).join(" s, ") + " s" + pause.moved); await sleep(1500); const again = await control(A, 3, "play"); await together(`${label}: both play again`, 3, true, again.at); // Ben seeks to 60 s. const seek = await control(B, 4, "seek", 60e6); const t4 = await together(`${label}: both go to 60 s on Ben's seek`, 4, true, seek.at); check(`${label}: a seek takes every player to the position it names`, t4.every(([, c]) => c.current_time >= 59.5 && c.current_time < 75), t4.map(([n, c]) => `${n} at ${c.current_time.toFixed(2)} s`).join(", ")); await sleep(3000); // Cleo joins late, and is brought to where the room is. const C = await enter("cleo"); const t5 = await together(`${label}: the late joiner is brought to the room's position`, 4, true); const late = t5.find(([n]) => n === "cleo")[1]; check(`${label}: a viewer that joins late reads the room's controls and seeks to the room's position`, late.seeks >= 1 && late.current_time >= 60, `cleo at ${late.current_time.toFixed(2)} s, ${(late.error_us / 1000).toFixed(1)} ms from the room, after ${late.seeks} seek(s)`); // Two windows with all three present and nothing changing, then their skew statements. const joinedC = C.s.cowatch.joined; const target = Math.ceil((joinedC + 2_000_000) / 5_000_000) * 5_000_000 + 10_000_000; const final = await until(`${label}: the clock states the windows after the late joiner settled`, STEP * 2, async () => { const r = await fetch(`${srv.url}/room-api/watch/room`).then((x) => x.json()); const lw = r.skews && r.skews.length && r.skews[0]; return lw && lw.to >= target && r; }, 500); const lastWindow = final.skews; const vs = lastWindow.flatMap((s) => s.viewers); check(`${label}: the room clock's last window states all three viewers in sync within the bound`, vs.length === 3 && vs.every((v) => v.verdict === "in_sync" && v.pq <= BOUND_US && v.counted > 0 && v.counted === v.due), vs.map((v) => `${v.principal.slice(0, 8)} ${v.verdict} p95 ${(v.pq / 1000).toFixed(1)} ms (${v.counted}/${v.due})`).join(", ")); // The room takes a page silent past its limit for gone (a loaded host can hold a page's // thread that long, as in its entering): the page's next post is refused `not_present`, and // it enters again and signs again what the room had not carried. That is the design at // work, not a fault. So the service's only refusals are those, one for each time a page was // taken for gone, and each page entered again as often (at most three times), with no other // event of it refused and no page stopped. The service counts a refusal before the page // it answers can, so the pages are read either side of the service, again every 500 ms // for at most 15 s, until the two readings of the pages agree and the service's count is // theirs; the check takes the last reading either way. const counts = (all) => JSON.stringify(all.map(([, c]) => [c.gone, c.rejoins])); const goneOf = (all) => all.reduce((n, [, c]) => n + c.gone, 0); let gone; for (const settle = Date.now() + 15000; ;) { const before = await cws(); const r = await fetch(`${srv.url}/room-api/watch/room`).then((x) => x.json()); const after = await cws(); gone = { pages: after, refused: r.refused || {} }; if ((counts(before) === counts(after) && (gone.refused.not_present || 0) === goneOf(after)) || Date.now() > settle) break; await sleep(500); } const goneSum = goneOf(gone.pages); check(`${label}: the service carried every event the pages posted, but where it took a page for gone, which entered again and went on`, Object.keys(gone.refused).every((k) => k === "not_present") && (gone.refused.not_present || 0) === goneSum && gone.pages.every(([, c]) => c.rejoins === c.gone && c.gone <= 3 && Object.keys(c.post_refused).length === 0 && !c.stopped), `the service refused ${JSON.stringify(gone.refused)}; ` + gone.pages.map(([n, c]) => `${n}: taken for gone ${c.gone}, entered again ${c.rejoins}, signed again ${c.resigned}, dropped ${c.dropped}${Object.keys(c.post_refused).length ? `, refused ${JSON.stringify(c.post_refused)}` : ""}${c.stopped ? `, stopped: ${c.stopped}` : ""}`).join("; ")); const shown = await until(`${label}: each page shows the room in sync`, STEP, async () => { const all = await cws(); return all.every(([, c]) => c.last_window && c.last_window.to >= target && c.dom && /in sync/.test(c.dom)) && all; }, 500); check(`${label}: each page shows its own offset and the room's verdicts`, shown.every(([, c]) => /^You: [+−]\d+ ms from the room · Room: all 3 in sync within 250 ms$/.test(c.dom)), shown.map(([n, c]) => `${n}: "${c.dom}"`).join("; ")); const all = await cws(); check(`${label}: no page refused a record of the log or had an event refused`, all.every(([, c]) => Object.keys(c.refused).length === 0 && Object.keys(c.post_refused).length === 0 && !c.stopped), all.map(([n, c]) => `${n}: ${c.statements} statements, ${c.posted} carried, ${c.nudges} nudged, ${c.seeks} seeks, taken for gone ${c.gone}, entered again ${c.rejoins}, signed again ${c.resigned}, dropped ${c.dropped}`).join("; ")); // The room as the service carried it, verified from the extension's text alone. const roomFile = path.join(dir, "watch-room.json"); fs.writeFileSync(roomFile, JSON.stringify(final)); const verifier = path.resolve(WEB, "../../../../../wai/live-conformance/co_watch_verify.py"); const v = JSON.parse(execFileSync("python3", ["-I", verifier, "--room", roomFile], { maxBuffer: 64 << 20 }).toString()); const checked = v.skews || []; const tail = checked.filter((x) => x.from >= target - 10_000_000 && x.viewers !== "unchecked"); check(`${label}: the room verifies by co_watch_verify.py, every skew statement recomputed, the controls applied in their order`, v.outcome === "accept" && checked.length > 0 && checked.every((x) => x.viewers !== "unchecked") && v.controls.length === 4 && tail.length > 0 && tail.every((x) => x.viewers.every((y) => y.verdict === "in_sync")), `${v.outcome}${v.reason ? ` ${v.reason} at ${JSON.stringify(v.at)}` : ""}: ${checked.length} skew statements, ${v.presentations ? v.presentations.length : 0} presentation statements, ${v.controls ? v.controls.length : 0} controls`); } finally { for (const p of pages) p.dump(); await browser.close().catch(() => {}); await srv.close(); await svc.stop(); await relay.stop(); killStrays(); } } // -------------------------------------------------------------------------------- termination // The host's verdicts on one stream log: wai-rs's (through wai_live_web) and the Python // verifier's, written from the extension's text. function hostVerdicts(records, dir, name) { const file = path.join(dir, `${name}.log.json`); fs.writeFileSync(file, JSON.stringify(records)); const rust = JSON.parse(execFileSync(WAI, ["termination-verify", file], { maxBuffer: 16 << 20 }).toString()); const py = JSON.parse(execFileSync("python3", ["-I", path.join(WAI_DIR, "live-conformance", "stream_termination_verify.py"), "--log", file], { maxBuffer: 16 << 20 }).toString()); return { rust, py, agree: JSON.stringify(sortKeys(rust)) === JSON.stringify(sortKeys(py)) }; } function sortKeys(v) { if (Array.isArray(v)) return v.map(sortKeys); if (v && typeof v === "object") return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])); return v; } // The kind of a record given in hexadecimal: its sixth byte (stream-termination §3.1). const kindOf = (h) => parseInt(h.slice(10, 12), 16); async function scenarioTermination(run) { const label = "termination"; const dir = path.join(OUT, `run${run}-termination`); fs.mkdirSync(dir, { recursive: true }); const corpus = path.join(WAI_DIR, "live-conformance", "termination", "vectors.json"); 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, "/vectors/termination.json": corpus }); const browser = await launch(dir); const bin = args.browser ? memguardedBrowser(path.resolve(args.browser), dir).executable : chromium.executablePath(); const pages = []; let bob = null; const GRACE = 3000; 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 = principalOf(fx, "alice"); const alice = await page(browser, roomUrl(srv.url, relay, file, { who: "alice", transport: "websocket" }), "alice", dir); pages.push(alice); await waitPhase(alice, "publishing"); await announce(clock, fx, "alice"); // The corpus through the page's wasm build. const fails = await alice.evaluate(() => window.roomTest.terminationCorpus("/vectors/termination.json")); check(`${label}: live-conformance/termination gives every match and log its stated outcome through the page's wasm build`, Array.isArray(fails) && fails.length === 0, fails.slice(0, 3).join(" | ")); // The receiving page, in a browser whose tab can go to the background. bob = await rawBrowser(bin, dir, "bob"); await bob.screen({ width: 1280, height: 720 }); await bob.goto(roomUrl(srv.url, relay, file, { who: "bob", transport: "websocket", unseen_grace_ms: String(GRACE) })); await until("bob publishing", STEP, async () => { const x = await bob.snap(); if (x && x.errors.length) throw new Error(x.errors[0]); return x && x.phase === "publishing"; }, 250); await announce(clock, fx, "bob"); const streamsOf = (x, m) => x.termination.streams.filter((y) => y.principal === A && y.media === m); const s0 = await until("bob decodes alice's video and audio, each under a log", STEP * 2, async () => { const x = await bob.snap(); if (x.errors.length) throw new Error(x.errors[0]); const t = x.subs[A]; const v = streamsOf(x, "video")[0]; const a = streamsOf(x, "audio")[0]; return t && t.video.decoded >= 20 && t.audio.decoded >= 40 && v && v.segments >= 2 && a && a.segments >= 2 && x; }, 500); const panel = s0.termination.panel; check(`${label}: the page declares the panel it reads (screen × devicePixelRatio, colour depth, dynamic range) and measures its refresh rate`, panel.long === 1280 && panel.short === 720 && panel.bit_depth === 8 && panel.hdr === false && s0.termination.refresh_measured && panel.refresh_milli > 0 && panel.long_mm === 0, JSON.stringify(panel)); const v0 = streamsOf(s0, "video")[0]; check(`${label}: the open record states how the stream started, the autoplay setting beside the user agent's answer, the policy, the panel and the ladder`, v0.open.start === "autoplay" && v0.open.autoplay === "on" && v0.open.unseen_grace_ms === GRACE && v0.open.ladder.length === 1 && v0.open.ladder[0].width === 320, JSON.stringify(v0.open)); // The tab goes behind another. await bob.hide(); await until("bob's document hidden", STEP, async () => (await bob.evaluate(() => document.visibilityState)) === "hidden", 100); const hiddenAt = Date.now(); const s1 = await until("bob terminates alice's video as hidden", STEP, async () => { const x = await bob.snap(); return streamsOf(x, "video").some((y) => y.terminated === "hidden") && x; }, 200); const after = (Date.now() - hiddenAt) / 1000; check(`${label}: a hidden tab ends the video it receives after its grace, with a signed termination`, after >= GRACE / 1000 - 0.3 && after <= GRACE / 1000 + 3, `terminated as hidden ${after.toFixed(1)} s after the tab was hidden (grace ${GRACE / 1000} s)`); const rv = s1.subs[A].video.received; const da = s1.subs[A].audio.decoded; await sleep(4000); const s2 = await bob.snap(); const moreVideo = s2.subs[A].video.received - rv; const moreAudio = s2.subs[A].audio.decoded - da; check(`${label}: its subscription is ended: the relay sends no more of the video, and the audio goes on`, moreVideo <= 8 && moreAudio >= 100 && s2.session.counters.unsubscribed >= 2, `in the 4 s after: ${moreVideo} video objects received (in flight at most), ${moreAudio} audio frames decoded; ${s2.session.counters.unsubscribed} subscriptions ended`); const logs = await bob.evaluate(() => window.roomTest.terminationLogs()); fs.writeFileSync(path.join(dir, "bob.logs.json"), JSON.stringify(logs, null, 1)); const vlog = logs.find((y) => y.principal === A && y.media === "video" && y.terminated === "hidden"); const alog = logs.find((y) => y.principal === A && y.media === "audio"); check(`${label}: nothing follows the termination in the log; what arrived after it was discarded unreceipted`, kindOf(vlog.records[vlog.records.length - 1]) === 5 && vlog.records.filter((h) => kindOf(h) === 5).length === 1, `${vlog.records.length} records, the last of kind ${kindOf(vlog.records[vlog.records.length - 1])}; ${vlog.discarded} objects discarded after it`); const hv = hostVerdicts(vlog.records, dir, "bob-video-hidden"); check(`${label}: the page's log verifies on the host, in wai-rs and in the Python verifier, with the facts the page states`, hv.rust.outcome === "accept" && hv.agree && hv.rust.terminated === "hidden" && vlog.verdict.outcome === "accept" && JSON.stringify(sortKeys(vlog.verdict)) === JSON.stringify(sortKeys(hv.rust)), `${hv.rust.outcome} ${hv.rust.terminated}, ${hv.rust.segments} segments, unseen ${hv.rust.unseen_us} µs, unwatched ${hv.rust.unwatched && hv.rust.unwatched.segments}; Python ${hv.agree ? "agrees" : "differs: " + JSON.stringify(hv.py).slice(0, 200)}`); const n = hv.rust.segments; check(`${label}: each segment's hops are the unmetered marker, never 0 J; the relay absent; the panel's match recorded`, JSON.stringify(hv.rust.unmetered) === JSON.stringify([{ role: "origin", count: n }, { role: "sink_decode", count: n }, { role: "display", count: n }]) && hv.rust.totals.length === 0 && JSON.stringify(hv.rust.absent) === '["relay"]' && hv.rust.panel.within === n, `${JSON.stringify(hv.rust.unmetered)}; absent ${JSON.stringify(hv.rust.absent)}; panel ${JSON.stringify(hv.rust.panel)}`); const replayed = [...vlog.records, vlog.records.find((h) => kindOf(h) === 2)]; const hr = hostVerdicts(replayed, dir, "bob-video-replayed"); check(`${label}: a segment receipt the page signed, replayed after the termination, is refused after_termination`, hr.rust.outcome === "refuse" && hr.rust.reason === "after_termination" && hr.rust.at === vlog.records.length && hr.agree, `${hr.rust.reason} at ${hr.rust.at}; Python ${hr.py.reason} at ${hr.py.at}`); check(`${label}: the audio's log stays open while the tab is hidden, its segments receipted`, alog && !alog.terminated && alog.verdict.outcome === "accept" && alog.verdict.unwatched === null, alog ? `${alog.segments} segments, ${alog.verdict.outcome}, terminated ${alog.verdict.terminated}` : "no audio log"); // In front again: a new log for the video. await bob.show(); const s3 = await until("bob subscribes to alice's video again under a new log", STEP, async () => { const x = await bob.snap(); const live = streamsOf(x, "video").filter((y) => !y.terminated); return x.termination.visibility === "visible" && live.length === 1 && live[0].segments >= 1 && x; }, 250); check(`${label}: in front again, the video is subscribed under a new log`, streamsOf(s3, "video").length === 2, `${streamsOf(s3, "video").length} video logs, ${s3.subs[A].video.decoded} frames decoded`); // A panel smaller than the video: declared again, the least rendition served, the match recorded. await bob.screen({ width: 1280, height: 720, screenWidth: 200, screenHeight: 150 }); const s4 = await until("bob declares the smaller panel and receipts segments under it", STEP, async () => { const x = await bob.snap(); if (x.termination.panel.long !== 200) return false; const ls = await bob.evaluate(() => window.roomTest.terminationLogs()); const live = ls.find((y) => y.principal === A && y.media === "video" && !y.terminated); const d = live && live.records.findIndex((h) => kindOf(h) === 4); return live && d > 0 && live.records.slice(d).some((h) => kindOf(h) === 2) && live; }, 500); const hp = hostVerdicts(s4.records, dir, "bob-video-panel"); check(`${label}: the panel shrinks below the video: the page declares it again, and its receipts record the match, the least rendition served`, hp.rust.outcome === "accept" && hp.agree && hp.rust.panel.none_within >= 1 && hp.rust.terminated === null, `panel ${JSON.stringify(hp.rust.panel)}; ${hp.rust.outcome}; Python ${hp.agree ? "agrees" : "differs"}`); await bob.screen({ width: 1280, height: 720 }); // The canvas out of the viewport: occluded. await bob.evaluate(() => { const c = document.getElementById("canvas"); c.style.position = "relative"; c.style.top = "-20000px"; }); const s5 = await until("bob terminates the video as occluded", STEP, async () => { const x = await bob.snap(); return streamsOf(x, "video").some((y) => y.terminated === "occluded") && x; }, 250); const ologs = await bob.evaluate(() => window.roomTest.terminationLogs()); const olog = ologs.find((y) => y.principal === A && y.media === "video" && y.terminated === "occluded"); const ho = hostVerdicts(olog.records, dir, "bob-video-occluded"); check(`${label}: the canvas moved out of the viewport ends the video as occluded, and the log verifies`, ho.rust.outcome === "accept" && ho.rust.terminated === "occluded" && ho.agree && s5.termination.occluded === true, `${ho.rust.outcome} ${ho.rust.terminated}; Python ${ho.agree ? "agrees" : "differs"}`); await bob.evaluate(() => { document.getElementById("canvas").style.top = "0px"; }); for (const p of [...pages, bob]) { const x = await p.snap(); fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1)); check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | ")); } } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } if (bob) { try { bob.dump(dir); } catch { /* closed */ } } const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); if (bob) await bounded(bob.close()); await bounded(browser.close()); await bounded(srv.close()); await bounded(relay.stop()); killStrays(); } } // ------------------------------------------------------------------------------- world // A room's shared world (WAI live-worlds), kept by a room service with `[world]`, its pages // web/world/. `sealed`: an end-to-end encrypted room, a private world (§8). const WORLD_TOML = `[world]\nmodel_id = "${"07".repeat(32)}"\nwidth = 64\nheight = 64\nfps = 24\n`; async function worldRoom(run, label, sealed) { const dir = path.join(OUT, `run${run}-${label}`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM) throw new Error(`--room is needed for the ${label} scenario`); const k = roomKeys(ROOM, dir); // The relay-lines' world door (§4.3), and a DVR window that keeps the room's world tracks for // a page that enters late (§7); it keeps nothing of a private world. const door = [{ prefix: ["*", "*", "world"], 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 }, dvr: { path: path.join(dir, "dvr-store"), max_age_s: 900, max_bytes: 64 * 1024 * 1024 } }, dir, "relay"); const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 5, ...(sealed ? { e2ee: true } : {}) }, extra: WORLD_TOML }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const browser = await launch(dir); const pages = []; const open = async (name, role = "publisher") => { const u = new URL(`${srv.url}/world/`); u.searchParams.set("service", "/room-api"); if (role !== "publisher") u.searchParams.set("role", role); const p = await page(browser, u.toString(), name, dir); p.snap = () => p.evaluate(() => window.worldTest && window.worldTest.snapshot()); pages.push(p); await until(`${name}'s page ready`, STEP, () => p.evaluate(() => !!window.worldTest), 200); return p; }; const enter = async (name, role = "publisher") => { const p = await open(name, role); await p.click("#enter"); const want = role === "publisher" ? "acting" : "watching"; const s = await until(`${name} in the room`, STEP, async () => { const x = await p.snap(); if (x && x.errors.length) throw new Error(`${name}: ${x.errors[0]}`); return x && x.phase === want && x; }, 200); return { p, me: s.me, 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 moves = async (p, list) => { for (const [dx, dy] of list) await p.evaluate(([x, y]) => window.worldTest.act(x, y), [dx, dy]); await p.evaluate(() => window.worldTest.flushed()); }; // Every page holds `n` ops and one session hash. const converge = async (what, ps, n) => { try { return await until(what, STEP * 2, async () => { const ss = await Promise.all(ps.map(snap)); return ss.every((s) => s.ops === n) && new Set(ss.map((s) => s.session_hash)).size === 1 && ss; }, 300); } catch (e) { const ss = await Promise.all(ps.map((p) => p.snap().catch((x) => ({ error: String(x) })))); for (const [i, s] of ss.entries()) fs.writeFileSync(path.join(dir, `${ps[i].name}.at-failure.json`), JSON.stringify(s, null, 1)); throw new Error(`${e.message}: ${ss.map((s, i) => `${ps[i].name} ops ${s.ops} hash ${String(s.session_hash).slice(0, 12)} followed ${(s.actors_followed || []).length} verified ${s.stats && s.stats.verified} refused ${JSON.stringify(s.stats && s.stats.refused)} offer_refused ${JSON.stringify(s.stats && s.stats.offer_refused)} own ${JSON.stringify(s.own)} errors ${(s.errors || []).length}`).join("; ")}`); } }; const finish = async () => { for (const p of pages) { try { fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(await p.snap(), null, 1)); } catch { /* closed */ } 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.worldTest && window.worldTest.leave())); await bounded(browser.close()); await bounded(srv.close()); await bounded(svc.stop()); await bounded(relay.stop()); killStrays(); }; return { dir, relay, svc, srv, open, enter, snap, moves, converge, finish, pages }; } // Two pages share a public world through the relay, one enters late and catches up from group // 0, a viewer reads it and acts in nothing, the relay refuses what a grant does not allow, and its // door drops a group whose receipt is not its owner's. async function scenarioWorld(run) { const label = "world"; const w = await worldRoom(run, label, false); try { const A = await w.enter("alice"); const B = await w.enter("bob"); check(`${label}: both pages are in one room, over the WebSocket binding, each with an actor's grant`, A.room === B.room && (await w.snap(A.p)).transport.kind === "websocket" && (await w.snap(B.p)).grant.role === "publisher", A.room.slice(0, 16)); await w.moves(A.p, [[1, 0], [0, 1], [1, 1]]); await w.moves(B.p, [[-1, 0], [0, -1], [-1, -1]]); const [sa, sb] = await w.converge(`${label}: alice and bob converge`, [A.p, B.p], 6); check(`${label}: two pages share one world through the relay: six ops, one session hash`, sa.session_hash === sb.session_hash && sa.by_actor[A.me] === 3 && sa.by_actor[B.me] === 3, sa.session_hash); check(`${label}: every object each received verified against its receipt, under its owner's chain, and none was refused`, sa.stats.verified >= 3 && sb.stats.verified >= 3 && !Object.keys(sa.stats.refused).length && !Object.keys(sb.stats.receipts_refused).length && !Object.keys(sa.stats.offer_refused).length && !Object.keys(sb.stats.offer_refused).length, `alice ${sa.stats.verified} verified, chain ${JSON.stringify(sa.stats.chain)}; bob ${sb.stats.verified}, chain ${JSON.stringify(sb.stats.chain)}`); // Carol enters late: both tracks fetched from group 0, every group verified, the world whole. const C = await w.enter("carol"); const [, , sc] = await w.converge(`${label}: carol catches up`, [A.p, B.p, C.p], 6); const fa = sc.stats.fetches.find((f) => f.owner === A.me), fb = sc.stats.fetches.find((f) => f.owner === B.me); check(`${label}: a late joiner fetches each track from group 0 and its world is the others' (catch-up, §7)`, fa && fb && fa.first === 0 && fa.objects === 3 && fb.first === 0 && fb.objects === 3 && sc.stats.incomplete.length === 0 && sc.session_hash === sa.session_hash, `alice ${fa && `${fa.first}..${fa.last}`}, bob ${fb && `${fb.first}..${fb.last}`}, ${sc.stats.fetched} fetched, hash ${sc.session_hash.slice(0, 16)}`); await w.moves(C.p, [[0, 0]]); const [s3] = await w.converge(`${label}: carol's move reaches everyone`, [A.p, B.p, C.p], 7); check(`${label}: the late joiner acts and every page takes it`, s3.by_actor[C.me] === 1, s3.session_hash); // A viewer reads the world and acts in nothing; the relay refuses its PUBLISH of a world // track, and an actor's PUBLISH of another principal's (§5.2). const V = await w.enter("viewer", "viewer"); const [, sv] = await w.converge(`${label}: the viewer reads the world`, [A.p, V.p], 7); check(`${label}: a viewer reads the whole world, every object verified`, sv.session_hash === s3.session_hash && sv.stats.verified >= 7, `${sv.stats.verified} verified`); const vpub = await V.p.evaluate(async () => { const c = window.worldTest.client; try { await c.session.publish([c.roomId, c.me, "world"], "0", 90, { publisher_priority: 48 }); return "accepted"; } catch (e) { return String(e.message || e); } }); check(`${label}: the relay refuses a viewer's PUBLISH of a world track`, /REQUEST_ERROR/.test(vpub), vpub); const xpub = await A.p.evaluate(async (other) => { const c = window.worldTest.client; try { await c.session.publish([c.roomId, other, "world"], "0", 91, { publisher_priority: 48 }); return "accepted"; } catch (e) { return String(e.message || e); } }, B.me); check(`${label}: the relay refuses an actor's PUBLISH of another principal's world track`, /REQUEST_ERROR/.test(xpub), xpub); // A group of Alice's track receipted by another key: the door drops it, and no page takes it. const before = (await w.snap(B.p)).ops; await A.p.evaluate(async () => { const c = window.worldTest.client; const other = new c.moq.TrackSigner(crypto.getRandomValues(new Uint8Array(32)), 4n, 30n, 31n, 48, "world", 0n); other.setEnvelope(false); const op = (await import("../pkg/wai_live_web.js")).worldAction(c.seed, BigInt(++c.lamport), 9, 9); const g = c.nextOwn++; const sg = other.sign(BigInt(g), op, new Date().toISOString(), new Date().toISOString()); await c.session.sendStream(sg.receiptsStream(), 3_000_001); await c.session.sendStream(sg.mediaStream(), 3_000_000); sg.free(); }); await sleep(3500); const after = await w.snap(B.p); const door = w.relay.lines.filter((l) => l.includes("refused at the door")).length; check(`${label}: the relay's door drops a group receipted by a key that is not its owner's, and no page takes it`, after.ops === before && door >= 1, `${door} refused at the door; bob ${before} → ${after.ops} ops`); // Alice leaves; Dave enters after and still fetches her track: her ops are the world's. await A.p.evaluate(() => window.worldTest.leave()); await sleep(1500); const D = await w.enter("dave"); const [sd] = await w.converge(`${label}: dave catches up after alice left`, [D.p, B.p], 7); const fd = sd.stats.fetches.find((f) => f.owner === A.me); check(`${label}: a page entering after an actor left fetches its track from the relay's window and holds the same world`, fd && fd.first === 0 && sd.session_hash === s3.session_hash, fd ? `${fd.objects} groups of alice's fetched` : "no fetch of alice's track"); for (const p of [B.p, C.p, V.p, D.p]) { const s = await p.snap(); check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | ")); } } finally { await w.finish(); } } // A private world (§8): every world object sealed under the room's MLS epoch keys before its // receipt is signed; members open, a viewer outside the group opens nothing; each member carries // what it holds into every epoch it reaches, so a member that joins later replays the world from // its start; a member that leaves opens no later epoch. async function scenarioPrivateWorld(run) { const label = "privateworld"; const w = await worldRoom(run, label, true); const joined = (p) => until(`${p.name} keyed`, STEP * 2, async () => { const s = await w.snap(p); return s.e2ee && s.e2ee.joined && s; }, 300); try { const A = await w.enter("alice"); await joined(A.p); await w.moves(A.p, [[1, 0], [0, 1]]); const B = await w.enter("bob"); await joined(B.p); // Bob opens nothing Alice sealed before he joined; she carries her ops into his epoch. const [sa, sb] = await w.converge(`${label}: bob receives alice's carry`, [A.p, B.p], 2); check(`${label}: a member that joins later gets the earlier ops by the others' carries, sealed in its epoch (§8.2)`, sa.stats.carried >= 1 && sb.ops === 2 && sb.stats.opened >= 1, `alice carried ${sa.stats.carried}, bob opened ${sb.stats.opened}, epochs alice ${sa.e2ee.epoch} bob ${sb.e2ee.epoch}`); await w.moves(B.p, [[-1, 0], [0, -1]]); await w.moves(A.p, [[1, 1]]); const [s1, s2] = await w.converge(`${label}: alice and bob converge`, [A.p, B.p], 5); check(`${label}: two members share one private world: five ops, one session hash`, s1.session_hash === s2.session_hash && s1.by_actor[B.me] === 2, s1.session_hash); // A viewer is not a member: it receives every object, verifies each receipt over the // sealed bytes, and opens none. // A viewer joins no epoch, so nothing is carried for it: it receives what is sealed after it // entered. const V = await w.enter("viewer", "viewer"); await sleep(1500); await w.moves(A.p, [[0, 0]]); await w.converge(`${label}: the members take alice's sixth`, [A.p, B.p], 6); await until(`${label}: the viewer receives sealed objects`, STEP, async () => { const s = await w.snap(V.p); return s.stats.verified >= 1 && s; }, 300); await sleep(1000); const sv = await w.snap(V.p); check(`${label}: a page outside the room's group verifies each receipt over ciphertext and opens nothing`, sv.stats.verified >= 1 && sv.ops === 0 && (sv.stats.open_skipped + Object.values(sv.stats.open_refused).reduce((a, b) => a + b, 0)) >= 1, `${sv.stats.verified} verified, ${sv.ops} ops, ${sv.stats.open_skipped} not opened`); // Carol joins third: Alice's and Bob's carries bring her every op. const C = await w.enter("carol"); await joined(C.p); const [s6a, , sc] = await w.converge(`${label}: carol replays the world from its start`, [A.p, B.p, C.p], 6); check(`${label}: a third member replays the private world from its start, by the others' carries, to the others' session hash`, sc.session_hash === s6a.session_hash && sc.stats.opened >= 1 && sc.e2ee.epoch > sb.e2ee.epoch, `${sc.ops} ops, opened ${sc.stats.opened}, epoch ${sc.e2ee.epoch}`); // The carries reach it because it followed both members' tracks before it published: its // world track is what records its join, and so brings the epoch they carry in (§8.2). check(`${label}: a member that enters later follows the members' tracks before it publishes, so their carries reach it`, sc.stats.followed_before_publish === 2, `${sc.stats.followed_before_publish} followed before publishing, ${sc.stats.carried} carried`); await w.moves(C.p, [[-1, 1]]); const [, s7b] = await w.converge(`${label}: carol's move reaches the members`, [A.p, B.p, C.p], 7); // Alice stops acting and stays: the clock records her leave, the next epoch excludes her, // and, still receiving, she opens nothing sealed after it. const aliceAt = (await w.snap(A.p)).ops; await A.p.evaluate(() => window.worldTest.client.stopActing()); await until(`${label}: bob's epoch moves past alice`, STEP * 2, async () => { const s = await w.snap(B.p); return s.e2ee.epoch > s7b.e2ee.epoch && s; }, 500); await w.moves(B.p, [[2, 2]]); await w.converge(`${label}: bob and carol go on`, [B.p, C.p], 8); await sleep(2000); const sl = await w.snap(A.p); check(`${label}: a member that left is removed, goes on receiving, and opens nothing sealed after the commit that removed it`, sl.ops === aliceAt && sl.stats.received > 0 && sl.e2ee.stats.removed != null, `alice ${aliceAt} → ${sl.ops} ops, removed at epoch ${sl.e2ee.stats.removed}, ${sl.stats.open_held} held, refused ${JSON.stringify(sl.stats.open_refused)}`); const sealedLine = w.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 private world`, !!sealedLine, (sealedLine || "no line").replace(/^.*INFO /, "").slice(0, 160)); for (const p of [B.p, C.p]) { const s = await p.snap(); check(`${label}: ${p.name} reports no error`, s.errors.length === 0 && !s.e2ee.stopped, [...s.errors.slice(0, 2), s.e2ee.stopped].filter(Boolean).join(" | ")); } } finally { await w.finish(); } } // ------------------------------------------------------------------------------------ source // The room's own source (WAI co-watch §15.7): a room service publishing a package's video and // sound, each object stamped with its playout timing and signed by a running receipt, through // a relay whose door holds each publisher's media to its own key, as `wai dev`'s does. A // viewer's page presents each frame at the instant its object states. Then the relay's // WebSocket to the page is held still for longer than the delay, and let through: what the // source sent meanwhile reaches the page past its instant, which the page must report late. // Nothing else is late: the view takes the run of the first group it is sent from the running // receipts the page read before it began, so no part of that group waits for its receipts. async function scenarioSource(run) { const label = "source"; const dir = path.join(OUT, `run${run}-source`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM || !WAI_DIR) throw new Error("--room and --wai-dir are needed for the source scenario"); const pkg = path.join(WAI_DIR, "pack-conformance", "multi", "multi-opus-01", "package"); const DELAY = 1000; const k = roomKeys(ROOM, dir); const door = ["video", "audio"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 4, join_wait_s: 30 }, top: { public_prefix: JSON.stringify("/room-api") }, extra: `[source]\npackage = ${JSON.stringify(pkg)}\nplayout_delay_ms = ${DELAY}\nmax_delay_ms = 2000\n`, }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const browser = await launch(dir); let p = null; try { p = await page(browser, `${srv.url}/room/?service=/room-api`, "viewer", dir); await until(`${label}: the page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); await p.click("#watch"); await waitPhase(p, "watching"); const src = async () => { const x = await p.snap(); if (x.errors.length) throw new Error(x.errors[0]); return x.source; }; const first = await until(`${label}: the page presents the source`, STEP, async () => { const x = await src(); return x && x.tracks.video && x.tracks.video.summary.on_time >= 60 && x.tracks.audio && x.tracks.audio.summary.on_time >= 30 && x; }, 250); const bound = first.tracks.video.summary.bound_ms; // The planted lateness: every byte the relay sends the page held for longer than the delay. const held = { from: Date.now(), ms: DELAY + 700 }; const links = await srv.stall(held.ms); held.to = Date.now(); await sleep(DELAY + 3000); const s = await src(); const v = s.tracks.video; const a = s.tracks.audio; // Objects that reached the page while it was held, or as it caught up after. const inHold = (recv) => recv >= held.from - 5 && recv <= held.to + 400; const frames = v.frames.filter((f) => f.target != null); const outside = frames.filter((f) => !inHold(f.recv)); const off = outside.filter((f) => Math.abs(f.error_ms) > bound); const errs = outside.map((f) => Math.abs(f.error_ms)).sort((x, y) => x - y); check(`${label}: every frame the page was sent in time is presented within ${bound} ms of its instant (capture plus delay, and its offset in its object)`, outside.length >= 120 && off.length === 0, `${outside.length} frames, |error| median ${errs[errs.length >> 1]?.toFixed(1)} ms, max ${errs[errs.length - 1]?.toFixed(1)} ms${off.length ? `; off: ${JSON.stringify(off.slice(0, 3))}` : ""}`); for (const [kind, t] of [["video", v], ["audio", a]]) { const late = t.late; const stray = late.filter((x) => !inHold(x.recv)); const said = (x) => `${x.group}/${x.object} ${Math.round(x.late_ms)} ms late, received ${Math.round(x.received_after_ms)} ms after its instant, on its ${x.verified === "run" ? "run" : "group's receipts"} ${Math.round(x.waited_ms)} ms after it came`; check(`${label}: ${kind} the relay held past its instant is reported late, and nothing else is`, links >= 1 && late.length >= 1 && stray.length === 0 && late.every((x) => x.late_ms > bound) && late.some((x) => x.received_after_ms > 0), `${late.length} late (${late.slice(0, 3).map(said).join("; ")}), ${stray.length} outside the hold of ${held.ms} ms on ${links} socket(s)${stray.length ? `: ${stray.slice(0, 3).map(said).join("; ")}` : ""}`); // The page subscribes to the source's events track, with a fill of its latest group, before // it reads the source's catalog and makes its view; the room is under a minute old, so that // group holds every running receipt since the source began. The view reads them first, and // takes the run of the first group it is sent from object 0 (live-http-egress §3.5, *The // run*; D96). A view that lost them joins inside that group, and its sound waits for the // group's receipts, past its instant where the page came early in the group. check(`${label}: ${kind}: the page holds the source's running receipts it read before its view began, so the view joins inside no group it is sent`, t.first_group != null && t.joined.every((g) => g < t.first_group), `first group sent ${t.first_group}, joined inside ${JSON.stringify(t.joined)}; ${JSON.stringify(t.by)}`); const sm = t.summary; // Objects before the first key frame a page can decode from, at its start, are the only // ones not presented, and each is counted with its reason. const reasons = [...new Set(t.refused.map((x) => x.reason))]; check(`${label}: every ${kind} object is accounted for: on time, late, refused with its reason, or in flight`, sm.received === sm.on_time + sm.late + sm.refused + sm.pending && sm.pending <= 40 && reasons.every((r) => r === "before_key_frame") && t.refused.every((x) => x.at < first.tracks.video.frames[0].presented + 1000), `${JSON.stringify(sm)}; refused ${JSON.stringify(reasons)}`); check(`${label}: ${kind} is verified on the source's running receipts before it is presented, none contradicted`, t.by.run > 0 && t.by.run >= 4 * t.by.receipts && t.contradicted === 0, `${JSON.stringify(t.by)}, ${t.contradicted} contradicted`); check(`${label}: ${kind} reports its HopTime, the relay's: its one-way delay and the least of the recent ones, none refused`, t.hop.last_ms != null && t.hop.oneway_ms != null && t.hop.baseline_ms != null && t.hop.baseline_ms <= t.hop.oneway_ms && Object.keys(t.hop.refused).length === 0, JSON.stringify(t.hop)); } check(`${label}: every running receipt the page read was taken or repeated, none refused`, s.records.taken > 0 && Object.keys(s.records.refused).length === 0, JSON.stringify(s.records)); const doorLines = relay.lines.filter((l) => l.includes("refused at the door")); check(`${label}: the relay's door refused nothing of the source`, doorLines.length === 0, doorLines.slice(0, 2).join(" | ")); const shown = await p.evaluate(() => document.getElementById("source-state").textContent); check(`${label}: the page shows what was on time, late and refused`, /video: \d+ on time, \d+ late/.test(shown) && /audio: /.test(shown), shown); fs.writeFileSync(path.join(dir, "source.json"), JSON.stringify({ held, source: s }, null, 1)); } finally { if (p) p.dump(); await browser.close().catch(() => {}); await srv.close(); await svc.stop(); await relay.stop(); } } // ----------------------------------------------------------------------------------- stitched // A stitched room (WAI live-ads §14.8): the room service's source is the programme, on a grid of // one-second Groups, and two breaks are filled for each session from two fixture creatives. A // viewer's page makes its session key, states its session and its device binding // (web/ads/traffic.js), and plays its session's composed video and sound, each object held on // the stitcher's running receipts (WAI live-http-egress §3.5, *A composed track's run*; D64) or, // where none covers it, its Group's receipts (aliases 200 and 201), and its stamps to the // composed timeline (live-ads §14.3). Then faults are planted in what the page is given, each // refused on the page: a byte flipped in a creative's Object held on its run; a Group whose // running receipts stop reaching the page at its third Object and whose receipts are the other // composed track's (another alias), so that its parts no running receipt covers have nothing to // verify on; and a composed Object with its timing taken off, held on its run. And one the page // must not refuse: a Group whose running receipts stop at its second Object and whose receipts // are its own, so that the parts no run covers wait for them and are presented on them. // The programme of a room with ads (the stitched and injector scenarios), in `dir`: the 25-frame // Groups of multi-opus-01's rung `hi`, a second each at 25 frames a second, and all of its sound; // with the package it was cut from, whose sound the creatives carry, and the creatives' package. function adsProgramme(dir) { const from = path.join(WAI_DIR, "pack-conformance", "multi", "multi-opus-01", "package"); const pkg = path.join(dir, "even"); const listing = []; let video = 0; for (const rung of ["hi", "a"]) { fs.mkdirSync(path.join(pkg, "floor", rung), { recursive: true }); fs.copyFileSync(path.join(from, "floor", rung, "init.mp4"), path.join(pkg, "floor", rung, "init.mp4")); } for (const l of fs.readFileSync(path.join(from, "floor.listing"), "utf8").split("\n")) { const [hash, name] = l.split(" "); if (!name) continue; const keep = name === "hi/seg_0001.m4s" || name === "hi/seg_0003.m4s" || name.startsWith("a/seg_"); if (!keep) continue; const rung = name.split("/")[0]; fs.copyFileSync(path.join(from, "floor", rung, `${hash}.m4s`), path.join(pkg, "floor", rung, `${hash}.m4s`)); listing.push(`${hash} ${rung === "hi" ? `hi/seg_${String(video++).padStart(4, "0")}.m4s` : name}`); } fs.writeFileSync(path.join(pkg, "floor.listing"), listing.join("\n") + "\n"); return { pkg, from, creatives: path.join(WAI_DIR, "pack-conformance", "packs", "video-cmaf-01", "package") }; } async function scenarioStitched(run) { const label = "stitched"; const dir = path.join(OUT, `run${run}-stitched`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM || !WAI_DIR) throw new Error("--room and --wai-dir are needed for the stitched scenario"); const { pkg, from, creatives } = adsProgramme(dir); const DELAY = 1500, FIRST = 10, EVERY = 6; const k = roomKeys(ROOM, dir); const relay = await startRelay(RELAY, { no_quic: true, auth: true, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 4, join_wait_s: 30 }, top: { public_prefix: JSON.stringify("/room-api") }, extra: `[source]\npackage = ${JSON.stringify(pkg)}\nframerate = 25\nplayout_delay_ms = ${DELAY}\nmax_delay_ms = 3000\n` + `[ads]\nfunds_ceiling = 10\ncurrency = "EUR"\nslots = 2\nfirst_break = ${FIRST}\nevery = ${EVERY}\nbreaks = 2\nbreak_groups = 2\n` + `asset_base = "https://ads.example/ads/list/"\ncreative_base = "https://ads.example/ads/creative/"\nclaim_grace_ms = 500\n` + `[[ads.creative]]\npackage = ${JSON.stringify(creatives)}\nfrom_group = 1\nad_id = "fixture-a"\ngroups = 1\nprice_micros = 300000\naudio_package = ${JSON.stringify(from)}\n` + `[[ads.creative]]\npackage = ${JSON.stringify(creatives)}\nfrom_group = 3\nad_id = "fixture-b"\ngroups = 1\nprice_micros = 200000\naudio_package = ${JSON.stringify(from)}\n`, }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const browser = await launch(dir); let p = null; try { p = await page(browser, `${srv.url}/room/?service=/room-api®ion=FR`, "viewer", dir); await until(`${label}: the page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); await p.click("#watch"); await waitPhase(p, "watching"); const snap = async () => { const x = await p.snap(); if (x.errors.length) throw new Error(x.errors[0]); return x; }; const groupOf = (x) => Number(String(x.key).split("/")[0]); // Both tracks presented through the first break and a Group after it. const first = await until(`${label}: the page presents its composed tracks through the first break`, 60000, async () => { const x = await snap(); const s = x.source; return s && s.stitched && s.tracks.video && s.tracks.audio && s.tracks.video.frames.some((f) => f.group >= FIRST + 3) && s.tracks.audio.summary.on_time >= 40 && x; }, 500); const s1 = first.source; check(`${label}: the page plays its own session's composed tracks, named by the session key it made`, s1.stitched.k === first.stitched.session && s1.stitched.k.length === 64, JSON.stringify(s1.stitched)); for (const kind of ["video", "audio"]) { const t = s1.tracks[kind]; check(`${label}: ${kind} is held on the stitcher's running receipts of the composed track as it comes, none contradicted`, t.by.run > 0 && t.by.run >= 4 * t.by.receipts && t.contradicted === 0, `${JSON.stringify(t.by)}, ${t.contradicted} contradicted`); const reasons = [...new Set(t.refused.map((x) => x.reason))]; check(`${label}: no ${kind} object is refused but before the first key frame, and its stamps hold to the composed timeline`, reasons.every((r) => r === "before_key_frame"), JSON.stringify(reasons)); } const shown = s1.tracks.video.frames.filter((f) => f.group >= FIRST && f.group < FIRST + 2); check(`${label}: the creatives' frames are presented, at the instants their stamps state`, shown.length >= 30 && shown.every((f) => f.target != null && Math.abs(f.error_ms) <= 200), `${shown.length} frames of Groups ${FIRST} and ${FIRST + 1}`); check(`${label}: the session's composed tracks state one capture clock`, typeof s1.clock === "number", String(s1.clock)); check(`${label}: every running receipt of the stitcher's the page read was taken, repeated or of a Group it joined inside, none refused`, s1.records.taken > 0 && Object.keys(s1.records.refused).length === 0, JSON.stringify(s1.records)); // The session and its binding, at the room service. const rec = await (await fetch(`${svc.url}/ads/records`)).json(); const bound = (rec.bindings || []).includes(first.stitched.binding); check(`${label}: the page's device binding (web/ads/traffic.js bind()) is in the room's records`, bound && rec.sessions && rec.sessions[first.stitched.session], `${(rec.bindings || []).length} binding(s)`); // The planted faults, in Groups of the second break and the one after it. const B = FIRST + EVERY; const UNRUN = 2; const plant = { flip: { kind: "video", group: B, object: 1 }, unrun: [{ kind: "audio", group: B + 1, from: UNRUN }, { kind: "video", group: B + 3, from: 1 }], alias: { kind: "audio", group: B + 1 }, untimed: { kind: "video", group: B + 2, object: 2 }, }; await p.evaluate((x) => { window.roomTest.sourceView.plant = x; }, plant); const after = await until(`${label}: the planted Groups have come`, 60000, async () => { const x = await snap(); return x.source.tracks.video.frames.some((f) => f.group >= B + 4) && x; }, 500); const s2 = after.source; const refusedAt = (kind, g) => s2.tracks[kind].refused.filter((x) => groupOf(x) === g); const objectOf = (x) => Number(String(x.key).split("/")[1]); check(`${label}: planted: the page was given each fault`, s2.planted.length >= 5, JSON.stringify(s2.planted)); const flip = refusedAt("video", B).find((x) => x.key === `${B}/1`); check(`${label}: planted: a byte flipped in a creative's Object held on its run is refused, content_hash_mismatch, and never decoded`, flip && flip.reason === "content_hash_mismatch" && !s2.tracks.video.frames.some((f) => f.group === B && f.object === 1), JSON.stringify(refusedAt("video", B).slice(0, 4))); // The parts the run covered were held on it, and stay held; every later part of the Group has // no running receipt, and its Group's receipts are refused: nothing covers it. const alias = refusedAt("audio", B + 1); check(`${label}: planted: a part no running receipt covers, of a Group whose receipts are the other composed track's (another alias), is refused with them, moqt_mismatch, and the parts its run covered are not`, alias.length >= 5 && alias.every((x) => x.reason === "moqt_mismatch" && objectOf(x) >= UNRUN), JSON.stringify(alias.slice(0, 3))); const untimed = refusedAt("video", B + 2).find((x) => x.key === `${B + 2}/2`); check(`${label}: planted: a composed Object with its timing taken off, held on its run, is refused, stitch_untimed, and not presented`, untimed && untimed.reason === "stitch_untimed" && !s2.tracks.video.frames.some((f) => f.group === B + 2 && f.object === 2), JSON.stringify(refusedAt("video", B + 2).slice(0, 4))); // A part no running receipt covers waits for its Group's receipts, and is presented on them. const waited = s2.tracks.video.frames.filter((f) => f.group === B + 3 && f.object >= 1 && f.frame === 0); check(`${label}: planted: the parts of a Group whose running receipts stopped at its second Object wait for its receipts and are presented on them, none refused`, refusedAt("video", B + 3).length === 0 && waited.length >= 5 && s2.tracks.video.by.receipts - s1.tracks.video.by.receipts >= 5, `${waited.length} parts of Group ${B + 3} presented; ${JSON.stringify(s2.tracks.video.by)}`); const stray = ["video", "audio"].flatMap((kind) => s2.tracks[kind].refused.filter((x) => !["before_key_frame", "content_hash_mismatch", "moqt_mismatch", "stitch_untimed"].includes(x.reason))); check(`${label}: planted: nothing else is refused, no running receipt is refused or contradicted, and the composed timeline holds after them`, stray.length === 0 && Object.keys(s2.records.refused).length === 0 && s2.tracks.video.contradicted === 0 && s2.tracks.audio.contradicted === 0, JSON.stringify({ stray: stray.slice(0, 3), records: s2.records })); fs.writeFileSync(path.join(dir, "stitched.json"), JSON.stringify({ first: s1, after: s2, records: { bindings: rec.bindings.length } }, null, 1)); } finally { if (p) p.dump(); await browser.close().catch(() => {}); await srv.close(); await svc.stop(); await relay.stop(); } } // An injector's captions in a room with ads (WAI live-ads §14.8, cue-injection §2, // essence-contribution §10.6, timed-text §4.4, §4.6): the room service grants an injector, for the // key its configuration names (`[ads] injectors`), the kinds `meta`, `cue` and `caption`, and no // media. The test holds that key, takes the grant from the service as a gateway does, and hands it // to a page that signs captions under it as the gateway's injector signs a contribution's: each // revision a partial with its whole text stable, its annotation and its window, then a final of the // same text superseding it when it leaves the screen, all three records anchored to a programme // video object, so that each record's MSF index is that object's Location (live-events §8.1). // Bob, a viewer that holds the whole stream, follows the injector's events once the clock records // it present, by its grant's kinds as /room lists them (it publishes no media), and shows its // CTA-708 windows 0 and 1 and its 608 data channel at once, one partial each, a caption that left // replaced by its final, and no AI system's marker; carol, joining inside a minute, shows at every // instant from the minute's start what bob shows. Planted and caught: a page that follows the // events of a principal with no media only as the page before did (co-performance, remote desktop, // AI participants) shows none of the injector's captions, though it holds the injector present; a // page showing the latest caption alone does not show the three windows. async function scenarioInjector(run) { const label = "injector"; const dir = path.join(OUT, `run${run}-${label}`); fs.mkdirSync(dir, { recursive: true }); if (!ROOM || !WAI_DIR) throw new Error("--room and --wai-dir are needed for the injector scenario"); const { pkg, from, creatives } = adsProgramme(dir); // The injector's key, which the room's configuration names, and whose seed the test holds. const seed = crypto.randomBytes(32); const pk = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]), format: "der", type: "pkcs8" }); const IP = crypto.createPublicKey(pk).export({ format: "der", type: "spki" }).subarray(-32).toString("hex"); const k = roomKeys(ROOM, dir); const relay = await startRelay(RELAY, { no_quic: true, auth: true, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); // No break falls inside the run: the programme alone, whose video objects the captions anchor to. const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 4, join_wait_s: 30 }, rate: { client_burst: 20 }, top: { public_prefix: JSON.stringify("/room-api") }, extra: `[source]\npackage = ${JSON.stringify(pkg)}\nframerate = 25\nplayout_delay_ms = 1500\nmax_delay_ms = 3000\n` + `[ads]\nfunds_ceiling = 10\ncurrency = "EUR"\nslots = 2\nfirst_break = 100000\nbreak_groups = 2\n` + `asset_base = "https://ads.example/ads/list/"\ncreative_base = "https://ads.example/ads/creative/"\nclaim_grace_ms = 500\ninjectors = [${JSON.stringify(IP)}]\n` + `[[ads.creative]]\npackage = ${JSON.stringify(creatives)}\nfrom_group = 1\nad_id = "fixture-a"\ngroups = 1\nprice_micros = 300000\naudio_package = ${JSON.stringify(from)}\n`, }, dir); const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); const base = `${srv.url}/room/?service=/room-api`; const browser = await launch(dir); const pages = []; const open = async (name) => { const p = await page(browser, base, name, dir); pages.push(p); await until(`${label}: ${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); return p; }; const watch = async (name, plant = null) => { const p = await open(name); if (plant) await p.evaluate((x) => window.roomTest.eventsPlant(x), plant); await p.click("#watch"); await waitPhase(p, "watching"); return p; }; try { const B = await watch("bob"); // Planted: the page before, which follows the events of a principal with no media only in a // room with co-performance, a remote desktop room or for an AI participant. const P = await watch("before", "media"); // The injector's grant, from the service, as a gateway takes it (`POST /grant`, role injector). const g = await fetch(`${svc.url}/grant`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ principal: IP, role: "injector" }) }); const answer = await g.json(); check(`${label}: the room service grants the key its configuration names the role injector`, g.status === 200 && answer.role === "injector" && answer.room && answer.room.room === (await B.snap()).room, `${g.status} ${answer.role || answer.error}`); const G = await open("gateway"); await G.evaluate((x) => window.roomTest.enter("injector", x), { seed: seed.toString("hex"), answer }); const gs = await waitPhase(G, "injecting"); check(`${label}: a page enters under the injector's grant, its key the one the configuration names`, gs.me.principal === IP && gs.grant.role === "injector", JSON.stringify(gs.me)); // Bob follows the injector's events once the clock records it present, by its grant's kinds. const follows = (p) => p.evaluate((ip) => window.roomTest.snapshot().events_subscribed.includes(ip), IP); await until(`${label}: bob follows the injector's events`, STEP, () => follows(B), 250); const ig = await B.evaluate((ip) => window.roomTest.state.grants.find((x) => x.principal === ip) || null, IP); check(`${label}: the injector's grant, as /room lists it to bob, permits meta, cue and caption alone, and no media (live-ads §14.8)`, ig && JSON.stringify([...ig.kinds].sort()) === JSON.stringify(["caption", "cue", "meta"]) && ig.publish === 0, JSON.stringify(ig && { kinds: ig.kinds, publish: ig.publish })); const present = (s) => ((s.roomState && s.roomState.present) || []).some((x) => x.principal === IP); await until(`${label}: the planted page holds the injector present`, STEP, () => P.evaluate((ip) => ((window.roomTest.state.room || {}).present || []).some((x) => x.principal === ip), IP), 250); // The programme's video object the captions are anchored to: its track's anchor (live-events // §3.1), SHA-256 over the namespace its catalog states, a zero byte and its name, and a Group // bob has presented. const roomV = await (await fetch(`${svc.url}/room`)).json(); const sourceP = roomV.source && roomV.source.principal; const track = crypto.createHash("sha256").update(Buffer.concat([Buffer.from(`${roomV.room}-${sourceP}-video`), Buffer.from([0]), Buffer.from("0")])).digest("hex"); const { group: shownGroup } = await until(`${label}: bob presents the programme`, STEP, async () => { const f = (((await B.snap()).source || {}).tracks || {}).video; return f && f.frames.length > 0 && { group: f.frames[f.frames.length - 1].group }; }, 500); const anchored = { track, group: shownGroup, object: 0 }; const now = () => G.evaluate(() => window.roomTest.now()); // A caption as the gateway's injector signs one: shown, a partial of no duration with its whole // text stable; when it leaves, its final, the same text, superseding it, its duration the time // left (at least 1 ms). Each record anchored to the programme object. const show = (o) => G.evaluate((o) => window.roomTest.sendRevision(o), { stable_cp: [...o.text].length, anchored, ...o }); const leave = (o, supersedes) => G.evaluate((o) => window.roomTest.sendRevision(o), { final: true, duration_ms: 1, supersedes, anchored, ...o }); // Into a minute's last 16 s: its records are in one group, and the minute turns after them. await until(`${label}: the room clock in a minute's last 16 s`, 75000, async () => { const x = (await now()) % 60e6; return x >= 44e6 && x < 48e6; }, 200); const gm = Math.floor((await now()) / 60e6) + 1; const W0 = { source: "cta-708", service: 1, window: 0 }, W1 = { source: "cta-708", service: 1, window: 1 }, CC1 = { source: "cta-608", service: 1 }; const e0 = { text: "An earlier line.", window: CC1 }; const e = await show(e0); await sleep(300); const a0 = { text: "Two windows", window: W0, anchor: "bottom-left" }; const a = await show(a0); await sleep(300); const b0 = { text: "shown at once", window: W1, anchor: "top-right" }; const b = await show(b0); await sleep(1200); // The 608 line leaves for the next: its final, then the next line's partial (§10.6). await leave(e0, e); const c0 = { text: "and one of 608", window: CC1 }; await show(c0); const want3 = { "cta-708/1/0": "Two windows", "cta-708/1/1": "shown at once", "cta-608/1/0": "and one of 608" }; const has3 = (L) => Object.entries(want3).every(([w, t]) => L.some((l) => l.window === w && l.text === t && !l.final)); const three = await until(`${label}: bob shows the injector's three windows at once`, 15000, async () => { const L = await captionLines(B); return has3(L) && L; }, 200); const inW = (w) => three.filter((l) => l.window === w); check(`${label}: bob shows the injector's CTA-708 windows 0 and 1 and its 608 data channel at once, one partial each (timed-text §4.4, §4.7)`, Object.keys(want3).every((w) => inW(w).length === 1), JSON.stringify(three.map((l) => [l.window, l.text]))); check(`${label}: each line is labelled as the contribution states its captions, a human captioner's, with no AI system's marker, and its whole text stable`, three.every((l) => l.label === "Human captioner" && !l.ai && l.rest === ""), JSON.stringify(three)); check(`${label}: the 608 line that left is shown no more, its final superseding it`, !three.some((l) => l.text === "An earlier line."), JSON.stringify(three.map((l) => l.text))); const sb0 = await B.snap(); const mine = (x) => x.timeline.filter((t) => t.principal === IP); const held = mine(sb0); check(`${label}: every record of the injector's bob holds is anchored to the programme object it was signed for, read from its MSF record indexed by that Location (live-events §8.1)`, held.length >= 15 && held.every((t) => t.anchor && t.anchor.track === track && t.anchor.group === shownGroup && t.anchor.object === 0), `${held.length} records; ${JSON.stringify(held.find((t) => !t.anchor || t.anchor.track !== track) || null)}`); // Planted: the page before. const sp = await P.snap(); const planted = await captionLines(P); check(`${label}: planted: a page that follows a principal with no media only as the page before did shows none of the injector's captions, though it holds it present, and is caught`, present(sp) && !sp.events_subscribed.includes(IP) && !has3(planted) && !planted.some((l) => Object.values(want3).includes(l.text)), JSON.stringify({ subscribed: sp.events_subscribed.includes(IP), lines: planted.map((l) => l.text) })); // Planted: the latest caption alone. await B.evaluate(() => window.roomTest.captionsPlant("latest")); const latest = await captionLines(B); await B.evaluate(() => window.roomTest.captionsPlant(null)); check(`${label}: planted: a page showing the latest caption alone does not show the injector's three windows, and is caught`, !has3(latest) && latest.length <= 1, JSON.stringify(latest.map((l) => l.text))); // The minute turns; window 0's caption leaves for the next, there alone. await until(`${label}: the minute turns`, 30000, async () => (await now()) >= gm * 60e6 + 1e6, 200); await leave(a0, a); const d0 = { text: "A new line", window: W0, anchor: "bottom-left" }; await show(d0); const fin = await until(`${label}: bob shows window 0's next line in place of the one that left`, 15000, async () => { const L = await captionLines(B); return L.some((l) => l.window === "cta-708/1/0" && l.text === "A new line") && L; }, 200); check(`${label}: a line that leaves is replaced in its window alone, and the other windows' lines stay`, !fin.some((l) => l.text === "Two windows") && fin.some((l) => l.window === "cta-708/1/1" && l.text === "shown at once") && fin.some((l) => l.window === "cta-608/1/0" && l.text === "and one of 608"), JSON.stringify(fin.map((l) => [l.window, l.text, l.final]))); // Carol joins inside the minute. const C = await watch("carol"); await until(`${label}: carol follows the injector's events`, STEP, () => follows(C), 250); await until(`${label}: carol shows the injector's windows`, STEP, async () => { const L = await captionLines(C); return L.some((l) => l.window === "cta-708/1/1") && L; }, 250); await leave(b0, b); await show({ text: "shown at once, and kept", window: W1, anchor: "top-right" }); await sleep(5000); // Up to 3 s ago: every record of an instant before it has reached both pages. const end = (await now()) - 3e6; const xs = []; for (let x = gm * 60e6; x <= end; x += 100e3) xs.push(x); const flat = (r) => r.map((q) => ({ at: q.at, lines: q.groups.flatMap((w) => w.lines.map((l) => ({ ...l, window: w.window }))).sort((p, o) => (p.principal < o.principal ? -1 : p.principal > o.principal ? 1 : p.seq - o.seq)) })); const [vb, vc] = await Promise.all([B.evaluate((xs) => window.roomTest.captionsAt(xs), xs), C.evaluate((xs) => window.roomTest.captionsAt(xs), xs)]); const fb = flat(vb), fc = flat(vc); const diff = fb.filter((q, i) => JSON.stringify(q) !== JSON.stringify(fc[i])); check(`${label}: carol, joining inside the minute, shows at every instant from its start what bob, holding the whole stream, shows (timed-text §4.6)`, xs.length >= 30 && diff.length === 0 && fb.some((q) => q.lines.some((l) => l.principal === IP)), `${xs.length} instants from ${(gm * 60).toFixed(0)} s, ${diff.length} differ${diff.length ? `: at ${diff[0].at} bob ${JSON.stringify(diff[0].lines.map((l) => l.text))}, carol ${JSON.stringify(fc[fb.indexOf(diff[0])].lines.map((l) => l.text))}` : ""}`); const atStart = fc[0].lines.filter((l) => l.principal === IP).map((l) => [l.window, l.text]); check(`${label}: at the minute's start carol shows the three lines she holds from the injector's join set alone, not the line that left before it`, JSON.stringify(atStart.sort()) === JSON.stringify([["cta-608/1/0", "and one of 608"], ["cta-708/1/0", "Two windows"], ["cta-708/1/1", "shown at once"]]), JSON.stringify(atStart)); const [sb, sc] = [await B.snap(), await C.snap()]; const texts = (x) => x.captions.filter((q) => q.who === IP.slice(0, 8)).map((q) => q.text); const [hb, hc] = [texts(sb), texts(sc)]; check(`${label}: carol holds the injector's captions from the join set on, not the line that left before the minute; bob holds every one`, hb.includes("An earlier line.") && !hc.includes("An earlier line.") && ["Two windows", "shown at once", "and one of 608", "A new line", "shown at once, and kept"].every((t) => hb.includes(t) && hc.includes(t)), JSON.stringify({ bob: hb, carol: hc })); check(`${label}: no page refused a caption stream, and the room each holds verifies with the injector's records (live-rooms §9)`, [sb, sc].every((x) => x.captionView.refused.length === 0 && x.roomState && x.roomState.verdict && x.roomState.verdict.outcome !== "refuse"), JSON.stringify([sb.captionView.refused, sc.captionView.refused, sb.roomState && sb.roomState.verdict, sc.roomState && sc.roomState.verdict])); fs.writeFileSync(path.join(dir, "captions.json"), JSON.stringify({ bob: sb.captionView, carol: sc.captionView, instants: xs.length }, null, 1)); } finally { for (const p of pages) p.dump(); await browser.close().catch(() => {}); await srv.close(); await svc.stop(); await relay.stop(); } } // ------------------------------------------------------------------------------------- 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")], ["webtransport23", (r) => scenarioRoom(r, "webtransport", 23)], ["websocket23", (r) => scenarioRoom(r, "websocket", 23)], ["goaway", scenarioGoaway], ["dvr", scenarioDvr], ["service", scenarioService], ["e2ee", scenarioE2ee], ["age", scenarioAge], ["co", scenarioCo], ["ageco", scenarioAgeCo], ["binding", scenarioBinding], ["cowatch", scenarioCowatch], ["source", scenarioSource], ["stitched", scenarioStitched], ["injector", scenarioInjector], ["origin", scenarioOrigin], ["termination", scenarioTermination], ["remote", scenarioRemote], ["world", scenarioWorld], ["privateworld", scenarioPrivateWorld], ["captioner", scenarioCaptioner]]) { 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);