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