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