joule-moq-web e2e: the service scenario seeks back from the relay's DVR window #377
2 changed files with 100 additions and 53 deletions
|
|
@ -91,11 +91,14 @@ closed. Delivery timeouts still apply at the relay, before a write.
|
|||
| `webtransport` | two participants publish and receive each other: frames decode, every received object verifies against its receipt and the relay's door refuses none, the canvas's tiles equal the host's layout for the same descriptor and set and show video, the mix reaches the AudioWorklet; a machine caption and a human caption show with their labels; a late joiner gets the earlier groups by FETCH, each verified, its chains admitting them, group 0 among them, as history, and every page re-lays out for three; the receipts objects ride in the binary form, their sizes reported beside the JSON form's; a grant presented without its proof is refused the session |
|
||||
| `websocket` | the same over the binding, forced |
|
||||
| `goaway` | the relay is stopped with GOAWAY naming a second relay; every page moves, media is verified again there, and the first relay closes no session at its timeout |
|
||||
| `service` | a room service keeps the room, as in production: the relay (WebSocket only, its door holding each publisher to its own key, trusting the service's authority) and the service behind a same-origin proxy; pages enter from their buttons with keys of their own. Two publish and see each other, every object verifies, the layout equals the host's, a machine caption and a typed human caption show their labels, the clock (not a page) authors the lifecycle, a late joiner gets earlier groups by FETCH, a viewer watches, a grant with no proof or with a proof by another key is refused the session, a publisher past the room's places is told it is full, and a leave is recorded |
|
||||
| `service` | a room service keeps the room, as in production: the relay (WebSocket only, its door holding each publisher to its own key, trusting the service's authority) and the service behind a same-origin proxy; pages enter from their buttons with keys of their own. Two publish and see each other, every object verifies, the layout equals the host's, a machine caption and a typed human caption show their labels, the clock (not a page) authors the lifecycle, a late joiner gets earlier groups by FETCH, a participant seeks back eight seconds into the other's tracks from the relay's DVR window (the relay keeps one and a cache far smaller than the span; the dvr scenario's checks: the control enables, the window's video plays in its own view from its first key frame at the pace it was captured, every object verifies, nothing is unknown, the page returns to live and live video goes on decoding, the relay served it from its window), a viewer watches, a grant with no proof or with a proof by another key is refused the session, a publisher past the room's places is told it is full, and a leave is recorded |
|
||||
| `e2ee` | an end-to-end encrypted room kept by a room service (`e2ee = true`), the relay keeping a DVR window: the page says so before anyone enters; two publishers key through the service's log (a GroupInfo, a commit, a Welcome), media flows both ways, every object verified against its receipt over the sealed bytes, opened, then decoded; the page shows its epoch and roster; a viewer, outside the group, receives the bytes exactly as sealed (no envelope, the AEAD's length) and opens nothing; every change a relay could make to an object is refused with the extension's reason (`bad_tag`, `key_id_missing`, `properties_malformed`, `object_id_too_large`, `key_id_unknown`, `sender_not_owner`, `replayed`) and the object opens once; an object changed on the wire is refused `bad_tag`; a late joiner's FETCH of earlier groups opens nothing sealed before its join (`epoch_not_joined`); a member that stops publishing and stays is removed by the next commit and opens nothing of the later epoch it still receives (`removed_before_epoch`), and an object it sealed before is refused `sender_removed`; the relay's DVR store keeps nothing of the room |
|
||||
|
||||
With `--base <origin>` the `service` scenario runs against a deployment instead: nothing is
|
||||
started, and the page at `<origin>/room/` is driven the same way (its production test).
|
||||
started, and the page at `<origin>/room/` is driven the same way (its production test). Its
|
||||
seek back is skipped there, and reported as skipped rather than passed, since the
|
||||
deployment's relay may keep no window; `--seek on` runs it, and `--seek off` skips it on
|
||||
loopback too.
|
||||
|
||||
Every wait is bounded; each browser runs under the host's `memguard` where it exists.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// node e2e/room.e2e.mjs --relay <joule-moq-relay> --wai <wai_live_web> --web <web dir>
|
||||
// --corpus <live-conformance/compose/vectors.json> [--browser <executable>]
|
||||
// [--only compose,webtransport,websocket,goaway,dvr,service,e2ee] [--runs N] [--between <s>]
|
||||
// [--out <dir>] [--room <joule-moq-room>] [--base <origin>]
|
||||
// [--out <dir>] [--room <joule-moq-room>] [--base <origin>] [--seek on|off]
|
||||
//
|
||||
// Scenarios:
|
||||
// compose live-conformance/compose run in the page: every layout, mix, descriptor
|
||||
|
|
@ -35,6 +35,9 @@
|
|||
// 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;
|
||||
|
|
@ -71,6 +74,11 @@ const [RELAY, WEB, CORPUS] = BASE ? [null, null, null] : [need("relay"), need("w
|
|||
const ROOM = args.room ? path.resolve(args.room) : null;
|
||||
const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway,dvr${ROOM ? ",service,e2ee" : ""}`)).split(",");
|
||||
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"));
|
||||
|
|
@ -84,6 +92,12 @@ function check(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); });
|
||||
|
||||
|
|
@ -413,7 +427,10 @@ async function scenarioService(run) {
|
|||
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the service scenario");
|
||||
const k = roomKeys(ROOM, dir);
|
||||
const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 }));
|
||||
relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay");
|
||||
// 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`;
|
||||
|
|
@ -489,6 +506,12 @@ async function scenarioService(run) {
|
|||
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 () => {
|
||||
|
|
@ -545,13 +568,77 @@ async function scenarioService(run) {
|
|||
if (relay) await bounded(relay.stop());
|
||||
killStrays();
|
||||
}
|
||||
if (relay && SEEK) {
|
||||
const w = servedFromWindow(path.join(dir, "summaries.jsonl"));
|
||||
check(`${label}: the relay served the seek back from its DVR window`, w.objects > 0, `${w.objects} objects from the window over ${w.lines} sessions`);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- dvr
|
||||
|
||||
// Seek back from a page's own control into `principal`'s tracks, as kept by a relay's DVR
|
||||
// window (WAI live-recording §8, live-rooms §10.6): the seconds back, then Seek back; the
|
||||
// window's video plays from its first key frame in a view of its own at the pace it was
|
||||
// captured, every object verified against its receipt; then the page is back to live and live
|
||||
// video goes on decoding. The dvr scenario (a fixture's room) and the service scenario (a room
|
||||
// service's, the seek power in its grants) hold a seek back to these same checks.
|
||||
const SEEK_SECONDS = 8;
|
||||
async function seekBackChecks(label, p, principal, dir) {
|
||||
const SECONDS = SEEK_SECONDS;
|
||||
// More than the seconds sought back of the other's video and audio, live at this page.
|
||||
const fps = 15;
|
||||
await until(`${label}: ${p.name} has ${SECONDS + 4} s of the other live`, STEP * 2, async () => {
|
||||
const s = await p.snap();
|
||||
if (s.errors.length) throw new Error(s.errors[0]);
|
||||
const t = s.subs[principal];
|
||||
return t && t.video.decoded > 0 && t.video.last_live_group != null && t.video.last_live_group - t.video.first_live_group >= (SECONDS + 4) * fps && t.audio.verified > 0;
|
||||
}, 500);
|
||||
|
||||
// The control: seconds back, then Seek back.
|
||||
await until(`${label}: the Seek back control is enabled`, STEP, () => p.evaluate(() => !document.getElementById("seek").disabled), 250);
|
||||
await p.fill("#seek-s", String(SECONDS));
|
||||
await p.click("#seek");
|
||||
const playing = await until(`${label}: ${p.name} plays from the window`, STEP, async () => {
|
||||
const s = await p.snap();
|
||||
return s.dvr.mode === "playing" && !(await p.evaluate(() => document.getElementById("dvr-canvas").hidden)) && s.dvr;
|
||||
}, 100);
|
||||
check(`${label}: the page shows the window's video in a view of its own while it plays`, playing.runs.length === 1, (await 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 SECONDS = 8;
|
||||
const dir = path.join(OUT, `run${run}-dvr`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const { fixture: fx, file } = makeFixture(WAI, ["alice", "bob"], dir);
|
||||
|
|
@ -576,49 +663,7 @@ async function scenarioDvr(run) {
|
|||
pages.push(bob);
|
||||
await waitPhase(bob, "publishing");
|
||||
await announce(clock, fx, "bob");
|
||||
// More than the seconds sought back of alice's video and audio, live at bob's.
|
||||
const fps = 15;
|
||||
await until(`${label}: bob has ${SECONDS + 4} s of alice live`, STEP * 2, async () => {
|
||||
const s = await bob.snap();
|
||||
if (s.errors.length) throw new Error(s.errors[0]);
|
||||
const t = s.subs[A];
|
||||
return t && t.video.decoded > 0 && t.video.last_live_group != null && t.video.last_live_group - t.video.first_live_group >= (SECONDS + 4) * fps && t.audio.verified > 0;
|
||||
}, 500);
|
||||
|
||||
// The control: seconds back, then Seek back.
|
||||
await until(`${label}: the Seek back control is enabled`, STEP, () => bob.evaluate(() => !document.getElementById("seek").disabled), 250);
|
||||
await bob.fill("#seek-s", String(SECONDS));
|
||||
await bob.click("#seek");
|
||||
const playing = await until(`${label}: bob plays from the window`, STEP, async () => {
|
||||
const s = await bob.snap();
|
||||
return s.dvr.mode === "playing" && !(await bob.evaluate(() => document.getElementById("dvr-canvas").hidden)) && s.dvr;
|
||||
}, 100);
|
||||
check(`${label}: the page shows the window's video in a view of its own while it plays`, playing.runs.length === 1, (await bob.evaluate(() => document.getElementById("dvr-status").textContent)).slice(0, 200));
|
||||
const done = await until(`${label}: bob returns to live`, STEP * 2, async () => {
|
||||
const s = await bob.snap();
|
||||
if (s.errors.length) throw new Error(s.errors[0]);
|
||||
const r = s.dvr.runs[0];
|
||||
return r && r.live_at != null && s.dvr.mode === "live" && s;
|
||||
}, 250);
|
||||
const r = done.dvr.runs[0];
|
||||
fs.writeFileSync(path.join(dir, "seek-back.json"), JSON.stringify(r, null, 1));
|
||||
const v = r.video, a = r.audio;
|
||||
check(`${label}: every fetched video object verifies against its receipt`, v.fetched > 0 && v.verified === v.fetched && v.refused === 0 && v.receipts_refused === 0,
|
||||
`groups ${v.from}..${v.to}: ${v.verified} of ${v.fetched} verified, ${v.refused} refused, ${v.receipts_refused} receipts refused`);
|
||||
check(`${label}: every fetched audio object verifies against its receipt`, a.fetched > 0 && a.verified === a.fetched && a.refused === 0 && a.receipts_refused === 0,
|
||||
`groups ${a.from}..${a.to}: ${a.verified} of ${a.fetched} verified, ${a.refused} refused`);
|
||||
check(`${label}: the window holds the whole span, nothing unknown`, v.fetched === v.to - v.from + 1 && a.fetched === a.to - a.from + 1 && v.unknown + a.unknown === 0,
|
||||
`video ${v.fetched} of ${v.to - v.from + 1} groups, audio ${a.fetched} of ${a.to - a.from + 1}; ${v.unknown + a.unknown} unknown`);
|
||||
const playedMs = r.live_at - r.playing_at;
|
||||
check(`${label}: the video plays from its first key frame at the pace it was captured`, v.decoded > 0 && v.played >= (SECONDS - 2) * fps * 0.5 && playedMs >= (SECONDS - 2.5) * 1000 && a.decoded > 0,
|
||||
`${v.played} frames shown, ${v.decoded} decoded, over ${(playedMs / 1000).toFixed(1)} s; ${a.decoded} audio frames decoded`);
|
||||
check(`${label}: the page is back to live, its view of the window hidden`, done.dvr.mode === "live" && (await bob.evaluate(() => document.getElementById("dvr-canvas").hidden)), done.dvr.mode);
|
||||
const d0 = done.subs[A].video.decoded;
|
||||
const after = await until(`${label}: live video goes on decoding after the seek back`, STEP, async () => {
|
||||
const s = await bob.snap();
|
||||
return s.subs[A].video.decoded >= d0 + 2 * fps && s.subs[A].video;
|
||||
}, 500);
|
||||
check(`${label}: live video goes on decoding after the seek back`, after.refused === 0, `${after.decoded} decoded (${d0} at the return), ${after.verified} verified`);
|
||||
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));
|
||||
|
|
@ -633,9 +678,8 @@ async function scenarioDvr(run) {
|
|||
killStrays();
|
||||
}
|
||||
// The relay's signed summaries: the objects the seek back fetched came from its window.
|
||||
const lines = fs.existsSync(summaries) ? fs.readFileSync(summaries, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
|
||||
const fromWindow = lines.reduce((n, l) => n + Object.values(l.summary.tally.subscriber.tracks || {}).reduce((m, t) => m + (t.dvr_objects || 0), 0), 0);
|
||||
check(`${label}: the relay served the seek back from its DVR window`, fromWindow > 0, `${fromWindow} objects from the window over ${lines.length} sessions`);
|
||||
const w = servedFromWindow(summaries);
|
||||
check(`${label}: the relay served the seek back from its DVR window`, w.objects > 0, `${w.objects} objects from the window over ${w.lines} sessions`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ e2ee
|
||||
|
|
@ -854,7 +898,7 @@ for (let run = 1; run <= RUNS; run++) {
|
|||
if (failed === before) passedRuns++;
|
||||
console.log(`== run ${run}: ${failed === before ? "passed" : "failed"} in ${((Date.now() - t) / 1000).toFixed(1)} s`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT, "results.json"), JSON.stringify(results, null, 1));
|
||||
console.log(`room e2e: ${passedRuns} of ${RUNS} runs passed; ${results.filter((r) => r.ok).length} of ${results.length} checks ok; artifacts in ${OUT}`);
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue