open-standards/wai/wai-audit/web/audit.test.mjs
Transaction Science 780b0c129f wai-audit: two player-integration tests the falsifications asked for
Of twelve rules of web/audit.js and its adapters broken one at a time,
two first went uncaught by the Node tests: an ad entered between two
observations (the corpus's observations fall on the ad's start, so only
the browser end to end caught it), and a stall begun before the first
presentation counted. A test for each; all twelve now fail the tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:59:26 -04:00

209 lines
12 KiB
JavaScript

// The player integration of stream audit (wai/extensions/stream-audit.md §13) against the corpus
// wai/live-conformance/audit/ (`node --test web/audit.test.mjs`): a session driven with the
// corpus's inputs signs the corpus's records byte for byte, and writes its log and its CMCD batch
// byte for byte; and the playlist reader, the tree root and the network tap do what §4 to §7 say.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import * as au from "./audit.js";
import { blake3 } from "./blake3.js";
import { tap } from "./adapter-tap.js";
import { loaderAudit } from "./adapter-hlsjs.js";
const doc = JSON.parse(readFileSync(new URL("../../live-conformance/audit/vectors.json", import.meta.url), "utf8"));
const vector = (name) => doc.sessions.find((v) => v.name === name);
const enc = new TextEncoder();
/** Session key `i` of the corpus: SHA-256 of its label and `i` (its README). */
const key = async (i) => au.keyFromSeed(await au.sha256(enc.encode("wai:stream-audit/corpus/key\x00"), Uint8Array.of(i)));
const T0 = 1_792_886_400_000_000;
const ms = (t) => T0 / 1000 + t;
/** The playlist the ads of `live_two_origins` are marked in: its creatives' segments and program times. */
const PLAYLIST = [
"#EXTM3U",
"#EXT-X-TARGETDURATION:2",
"#EXT-X-PROGRAM-DATE-TIME:2026-10-25T00:00:06.000Z",
'#EXT-X-DATERANGE:ID="ad-1",START-DATE="2026-10-25T00:00:06.000Z",DURATION=4.000,SCTE35-OUT=0xFC302000',
"#EXTINF:2.000,",
"https://cdn-b.example/a0",
"#EXTINF:2.000,",
"https://cdn-b.example/a1",
"#EXT-X-PROGRAM-DATE-TIME:2026-10-25T00:00:14.000Z",
'#EXT-X-DATERANGE:ID="ad-2",START-DATE="2026-10-25T00:00:14.000Z",DURATION=4.000,SCTE35-OUT=0xFC302000',
"#EXTINF:2.000,",
"https://cdn-b.example/a2",
"#EXTINF:2.000,",
"https://cdn-b.example/a3",
"",
].join("\n");
test("BLAKE3 of one chunk: the specification's empty input, and the corpus's tree roots", () => {
assert.equal(au.hex(blake3(new Uint8Array(0))), "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262");
assert.throws(() => blake3(new Uint8Array(1025)));
});
test("a live session from two origins: every record, the log and the CMCD batch are the corpus's", async () => {
const v = vector("live_two_origins");
const sc = v.script;
let clock = T0;
const s = await au.AuditSession.start({ channel: "news-east", owner: doc.owner, presentation: au.LIVE, windowUs: sc.window_us, key: await key(sc.key), now: () => clock, cmcdEveryMs: 0 });
s.playlist(PLAYLIST, "https://cdn-b.example/live.m3u8");
const notes = { s0: { cmsdStatic: "ot=v,sf=h,st=l" }, s1: { cmsdDynamic: '"cdn-b";etp=1200;rtt=40' } };
const steps = [...sc.received.map((r) => ({ at: r.at, r })), ...sc.events.map((e) => ({ at: e.at, e }))].sort((a, b) => a.at - b.at);
// Playback, observed every 100 ms from 1 s to 29.5 s: stalled from 20 s to 21.5 s, the second ad
// 30 % in view, everything audible.
const ticks = [];
for (let t = 1000; t <= 29_500; t += 100) ticks.push({ at: T0 + t * 1000, t });
const all = [...steps, ...ticks.map((k) => ({ at: k.at, tick: k.t }))].sort((a, b) => a.at - b.at || (a.tick === undefined ? -1 : 1));
for (const x of all) {
clock = x.at;
if (x.e) s.event(x.e.event, x.at);
else if (x.r) {
const url = `${x.r.origin}/${x.r.object}`;
const n = notes[x.r.object];
if (n) delete notes[x.r.object];
await s.object({ url, bytes: au.unhex(doc.objects[x.r.object]), media: x.r.media, at: x.at, ...(n ?? {}) });
} else {
const t = x.tick;
const playing = t < 20_000 || (t >= 21_500 && t < 29_500);
s.observe({ at: x.at, playing, visibleBp: t >= 14_000 && t < 18_000 ? 3000 : 10_000, audible: true, programMs: ms(t) });
}
}
s.energy(au.ROLE.SINK_DECODE, 1_250_000, au.CLASS.ESTIMATOR);
s.energy(au.ROLE.DISPLAY, 0, au.CLASS.UNMETERED);
const bundle = await s.close("user", T0 + 30_000_000);
assert.equal(bundle.log, v.bundle.log, "the log");
assert.equal(bundle.notes, v.bundle.notes, "the notes");
assert.deepEqual(bundle.acks, v.bundle.acks, "the acknowledgements");
assert.deepEqual(bundle.claims, v.bundle.claims, "the claims");
assert.equal(bundle.session, v.bundle.session, "the session record");
assert.deepEqual(bundle, v.bundle);
assert.equal(au.cmcdBatch(s.log), v.cmcd, "the CMCD batch");
});
test("the playlist reader: segments, program times, marked ads, keys and on demand", () => {
const p = au.parsePlaylist(PLAYLIST + '#EXT-X-KEY:METHOD=AES-128,URI="k/1.key"\n#EXT-X-DATERANGE:ID="n",START-DATE="2026-10-25T00:01:00.000Z",DURATION=1.0,CLASS="x"\n#EXT-X-ENDLIST\n', "https://cdn-b.example/v/live.m3u8");
assert.deepEqual(p.segments.map((x) => [x.uri, x.durationMs, x.programMs]), [
["https://cdn-b.example/a0", 2000, ms(6000)],
["https://cdn-b.example/a1", 2000, ms(8000)],
["https://cdn-b.example/a2", 2000, ms(14_000)],
["https://cdn-b.example/a3", 2000, ms(16_000)],
]);
assert.deepEqual(p.ads.map((a) => [a.id, a.startMs, a.durationMs]), [["ad-1", ms(6000), 4000], ["ad-2", ms(14_000), 4000]], "a CLASS not configured is not an ad");
assert.deepEqual(p.keys, ["https://cdn-b.example/v/k/1.key"]);
assert.equal(p.onDemand, true);
assert.equal(au.parsePlaylist(PLAYLIST + '#EXT-X-DATERANGE:ID="n",START-DATE="2026-10-25T00:01:00.000Z",DURATION=1.0,CLASS="x"\n', "https://a.example/", { adClasses: ["x"] }).ads.length, 3);
});
test("a key a playlist names is never acknowledged, and a repeat in a window is counted once", async () => {
let clock = T0;
const s = await au.AuditSession.start({ channel: "news-east", owner: doc.owner, key: await key(10), now: () => clock, cmcdEveryMs: 0 });
s.playlist('#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="https://k.example/1.key"\n#EXTINF:2,\ns.ts\n#EXT-X-ENDLIST\n', "https://o.example/p.m3u8");
assert.equal(await s.object({ url: "https://k.example/1.key", bytes: new Uint8Array(16), media: false }), null);
await s.object({ url: "https://o.example/s.ts", bytes: enc.encode("segment"), media: true, at: T0 + 1000 });
await s.object({ url: "https://o.example/s.ts", bytes: enc.encode("segment"), media: true, at: T0 + 2000 });
const b = await s.close("ended", T0 + 3_000_000);
assert.equal(b.acks.length, 1);
assert.equal(b.acks[0].items[0].length, 96, "one item of 48 bytes, hexadecimal");
const rec = au.unhex(b.session);
assert.equal(rec[4 + 1 + 32 * 3 + 16], au.ON_DEMAND, "on demand, from EXT-X-ENDLIST");
});
/** A window with only what the tap reads: `fetch`, `XMLHttpRequest`, `location`. */
function fakeWindow(responses) {
const win = { location: { href: "https://page.example/" } };
win.fetch = async (input) => {
const url = new URL(input, win.location.href).href;
const r = responses[url];
return new Response(r.body, { status: 200, headers: r.headers ?? {} });
};
// Response.url is read-only; a real fetch sets it. Stand in with a subclass that has it.
const plain = win.fetch;
win.fetch = async (input, init) => {
const res = await plain(input, init);
const url = new URL(input, win.location.href).href;
Object.defineProperty(res, "url", { value: url });
const clone = res.clone.bind(res);
res.clone = () => {
const c = clone();
Object.defineProperty(c, "url", { value: url });
return c;
};
return res;
};
return win;
}
test("the tap hands the session what the presentation names, and nothing else", async () => {
const pl = '#EXTM3U\n#EXT-X-MAP:URI="init.mp4"\n#EXT-X-KEY:METHOD=AES-128,URI="k.key"\n#EXTINF:2,\nseg0.m4s\n#EXT-X-ENDLIST\n';
const win = fakeWindow({
"https://cdn.example/v/p.m3u8": { body: pl },
"https://cdn.example/v/init.mp4": { body: "init", headers: { "content-type": "video/mp4" } },
"https://cdn.example/v/seg0.m4s": { body: "seg0", headers: { "content-type": "video/iso.segment", "cmsd-static": "ot=v" } },
"https://cdn.example/v/k.key": { body: "0123456789abcdef" },
"https://analytics.example/x": { body: "{}" },
});
const s = await au.AuditSession.start({ channel: "film-library", owner: doc.owner, key: await key(11), cmcdEveryMs: 0 });
const t = tap(s, { manifest: "https://cdn.example/v/p.m3u8", win });
for (const u of ["https://cdn.example/v/p.m3u8", "https://cdn.example/v/init.mp4", "https://cdn.example/v/seg0.m4s", "https://cdn.example/v/k.key", "https://analytics.example/x"]) {
await (await win.fetch(u)).arrayBuffer();
await new Promise((r) => setTimeout(r, 20));
}
t.restore();
await Promise.all(s.pending);
const h = async (t) => au.hex(await au.sha256(enc.encode(t)));
assert.deepEqual(s.received.map((r) => [r.origin, r.media, au.hex(r.hash)]), [
["https://cdn.example", false, await h(pl)],
["https://cdn.example", true, await h("init")],
["https://cdn.example", true, await h("seg0")],
]);
assert.deepEqual(s.notes, [{ content_hash: au.hex(await au.sha256(enc.encode("seg0"))), cmsd_static: "ot=v", cmsd_dynamic: null }]);
assert.equal(s.presentation, au.ON_DEMAND);
});
test("the loader adapter wraps the player's loader, and passes key loads through untouched", async () => {
const seen = [];
class Base {
load(context, config, callbacks) {
seen.push(context.url);
const data = context.type === "media-fragment" ? new TextEncoder().encode("frag").buffer : "#EXTM3U\n#EXTINF:2,\nf.ts\n";
callbacks.onSuccess({ url: context.url, data, code: 200 }, {}, context, { getResponseHeader: (h) => (h === "cmsd-dynamic" ? '"e";etp=9' : null) });
}
}
const Player = { DefaultConfig: { loader: Base } };
const s = await au.AuditSession.start({ channel: "news-east", owner: doc.owner, key: await key(12), cmcdEveryMs: 0 });
const a = loaderAudit(Player, s);
const l = new a.Loader();
const got = [];
const cb = { onSuccess: (r) => got.push(r.url), onError() {}, onTimeout() {} };
l.load({ type: "level", url: "https://o.example/l.m3u8" }, {}, cb);
l.load({ type: "media-fragment", url: "https://o.example/f.ts", frag: {} }, {}, cb);
l.load({ type: "key", url: "https://o.example/k.key", keyInfo: {} }, {}, cb);
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(got, ["https://o.example/l.m3u8", "https://o.example/f.ts", "https://o.example/k.key"], "the player gets every answer");
assert.deepEqual(s.received.map((r) => [r.media, r.length]), [[false, 24], [true, 4]], "the key is not the session's");
assert.equal(s.segments.has("https://o.example/f.ts"), true);
assert.equal(s.notes.length, 2);
assert.equal(a.programMs({ playingDate: new Date(5000) }), 5000);
});
test("an ad entered between two observations is stated from its start, and to its end", async () => {
let clock = T0;
const s = await au.AuditSession.start({ channel: "news-east", owner: doc.owner, key: await key(13), now: () => clock, cmcdEveryMs: 0 });
s.playlist(PLAYLIST, "https://cdn-b.example/live.m3u8");
for (const n of ["a0", "a1"]) await s.object({ url: `https://cdn-b.example/${n}`, bytes: au.unhex(doc.objects[n]), media: true, at: T0 + 1000 });
// Every 200 ms from 1.03 s: the first observation within the ad is 30 ms into it, the last 170 ms before its end.
for (let t = 1030; t <= 12_000; t += 200) {
clock = T0 + t * 1000;
s.observe({ at: clock, playing: true, visibleBp: 10_000, audible: true, programMs: ms(t) });
}
const ad = [...s.ads.values()][0];
assert.deepEqual(ad.recorder.spans(), [{ startMs: 0, lengthMs: 4000, visibleBp: 10_000, flags: au.AUDIBLE }]);
});
test("the log's stalls are those begun after the first presentation (delivery-telemetry §7.2)", () => {
const o = (sn, t, event) => au.observation("00".repeat(16), sn, t * 1000, event, "l", { present: event === "present" ? { unit: 0, layers: 1, first: true, late_ms: null, decode_us: null } : null });
const log = [o(0, 0, "start"), o(1, 100, "stall-start"), o(2, 300, "stall-end"), o(3, 400, "present"), o(4, 900, "stall-start"), o(5, 1200, "stall-end"), o(6, 2000, "end")];
assert.deepEqual(au.sessionMetrics(log), { vst: 400, stalls: 1, stallMs: 300 });
});