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