open-standards/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs
2026-10-19 11:55:12 -04:00

656 lines
44 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] [--runs N] [--between <s>]
// [--out <dir>] [--room <joule-moq-room>] [--base <origin>]
//
// 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
// 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.
//
// 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 { serve, serveSameOrigin, startRelay, roomKeys, startRoomService, makeFixture, hostFrame, browserArgs, memguardedBrowser, killStrays, tmpdir, until, sleep } from "./lib.mjs";
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" : ""}`)).split(",");
const RUNS = Number(args.runs || 1);
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}` : ""}`);
}
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. Over the WebSocket binding the page reads
// the code (2) and the reason; over WebTransport the relay's transport closes the QUIC
// connection rather than the WebTransport session, so the page sees the session lost,
// and the relay's log gives the reason.
const bare = await alice.evaluate(() => window.roomTest.tryBareGrant());
const refusals = relay.lines.filter((l) => l.includes("without a proof that passes"));
const coded = bare.transport === "websocket" ? !!bare.closed && bare.closed.code === 2 : !!bare.closed;
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 }));
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");
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));
// 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();
}
}
// ------------------------------------------------------------------------------------- dvr
async function scenarioDvr(run) {
const label = "dvr";
const SECONDS = 8;
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");
// More than the seconds sought back of alice's video and audio, live at bob's.
const fps = 15;
await until(`${label}: bob has ${SECONDS + 4} s of alice live`, STEP * 2, async () => {
const s = await bob.snap();
if (s.errors.length) throw new Error(s.errors[0]);
const t = s.subs[A];
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, () => bob.evaluate(() => !document.getElementById("seek").disabled), 250);
await bob.fill("#seek-s", String(SECONDS));
await bob.click("#seek");
const playing = await until(`${label}: bob plays from the window`, STEP, async () => {
const s = await bob.snap();
return s.dvr.mode === "playing" && !(await bob.evaluate(() => document.getElementById("dvr-canvas").hidden)) && s.dvr;
}, 100);
check(`${label}: the page shows the window's video in a view of its own while it plays`, playing.runs.length === 1, (await bob.evaluate(() => document.getElementById("dvr-status").textContent)).slice(0, 200));
const done = await until(`${label}: bob returns to live`, STEP * 2, async () => {
const s = await bob.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 bob.evaluate(() => document.getElementById("dvr-canvas").hidden)), done.dvr.mode);
const d0 = done.subs[A].video.decoded;
const after = await until(`${label}: live video goes on decoding after the seek back`, STEP, async () => {
const s = await bob.snap();
return s.subs[A].video.decoded >= d0 + 2 * fps && s.subs[A].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`);
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 lines = fs.existsSync(summaries) ? fs.readFileSync(summaries, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
const fromWindow = lines.reduce((n, l) => n + Object.values(l.summary.tally.subscriber.tracks || {}).reduce((m, t) => m + (t.dvr_objects || 0), 0), 0);
check(`${label}: the relay served the seek back from its DVR window`, fromWindow > 0, `${fromWindow} objects from the window over ${lines.length} sessions`);
}
// ------------------------------------------------------------------------------------- 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]]) {
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(results, null, 1));
console.log(`room e2e: ${passedRuns} of ${RUNS} runs passed; ${results.filter((r) => r.ok).length} of ${results.length} checks ok; artifacts in ${OUT}`);
killStrays();
process.exit(failed ? 1 : 0);