e2e/run.sh builds the collector, installs the automation package and an open-source web player from e2e/package-lock.json (npm ci, no install scripts), runs the player integration's unit tests, then e2e/audit.e2e.mjs: an HLS stream made with the host's media tool (H.264 and AAC in 2 s CMAF segments, a program time, a marked ad over two segments) served from another origin with CMSD-Static exposed, played in a real browser twice, once through the loader adapter and once through the network tap, each session sent to a collector on loopback. It checks the outcome, the acknowledged objects against the origin's own SHA-256 of what it served, the ad's claim, the CMSD notes, the unmetered energy, the CMCD batches, the report, its recomputation by `wai-audit check`, a replay stored once and a tampered record refused. Three defects it found, fixed here: - the player hands fragment buffers on (to a worker), emptying them after the loader's callback: the session now copies the bytes at once, and keeps each object in the order it was given; - the tap read XMLHttpRequest responses after the player had moved them: it now listens from the request's construction, so it reads first; - a stitched ad is entered between two observations, so its claim began past media time 0 and reached quartile 1: play that enters an ad continuously now starts its first span at 0 (stream-audit §6.3), as leaving it already ran the last span to the ad's end. Co-Authored-By: Claude Opus 5.5 <[email protected]>
82 lines
3.6 KiB
JavaScript
82 lines
3.6 KiB
JavaScript
// The network tap: stream audit's integration for any web player that fetches its playlists and
|
|
// segments with the page's `fetch` or `XMLHttpRequest` (wai/extensions/stream-audit.md §13).
|
|
//
|
|
// It wraps both, and hands the session (./audit.js) every successful response (200 or 206) whose
|
|
// URL the presentation names: the playlist or manifest the page gives it, every URI a playlist
|
|
// it saw lists (variants, segments, `EXT-X-MAP`), and, under a DASH manifest, every response of a
|
|
// media type. A key a playlist names is never handed over (§4.1), and neither is anything else
|
|
// the page fetches. The response is read from a copy (`Response.clone()`), so the player's own
|
|
// read is untouched.
|
|
|
|
const dec = new TextDecoder();
|
|
const enc = new TextEncoder();
|
|
const MEDIA_TYPES = /^(video\/|audio\/|application\/mp4|application\/vnd\.apple\.mpegurl|application\/x-mpegurl)/i;
|
|
|
|
const isPlaylist = (bytes) => bytes.length >= 7 && dec.decode(bytes.subarray(0, 7)) === "#EXTM3U";
|
|
const isMpd = (bytes) => /<MPD[\s>]/.test(dec.decode(bytes.subarray(0, 2048)));
|
|
|
|
/**
|
|
* Tap `win`'s `fetch` and `XMLHttpRequest` for `session`, starting from the presentation at
|
|
* `manifest`. Gives `{ restore }`, which puts both back.
|
|
*/
|
|
export function tap(session, { manifest, win = globalThis } = {}) {
|
|
const known = new Set([new URL(manifest, win.location?.href).href]);
|
|
let dash = false;
|
|
const relevant = (url, type) => known.has(url) || session.segments.has(url) || (dash && MEDIA_TYPES.test(type ?? ""));
|
|
const handle = async (url, bytes, status, header) => {
|
|
if ((status !== 200 && status !== 206) || session.isKey(url)) return;
|
|
let media = true;
|
|
if (isPlaylist(bytes)) {
|
|
media = false;
|
|
const p = session.playlist(dec.decode(bytes), url);
|
|
for (const s of p.segments) known.add(s.uri);
|
|
if (p.map) known.add(p.map);
|
|
} else if (isMpd(bytes)) {
|
|
media = false;
|
|
dash = true;
|
|
}
|
|
await session.object({ url, bytes, media, status, cmsdStatic: header("cmsd-static"), cmsdDynamic: header("cmsd-dynamic") });
|
|
};
|
|
|
|
const fetch0 = win.fetch;
|
|
win.fetch = async function (input, init) {
|
|
const res = await fetch0.call(this ?? win, input, init);
|
|
try {
|
|
if (relevant(res.url, res.headers.get("content-type"))) {
|
|
const copy = res.clone();
|
|
copy.arrayBuffer().then((b) => handle(res.url, new Uint8Array(b), res.status, (h) => res.headers.get(h))).catch(() => {});
|
|
}
|
|
} catch {
|
|
// An opaque or already-read response: not the presentation's, or nothing to hash.
|
|
}
|
|
return res;
|
|
};
|
|
|
|
// A listener added as the request is made runs before any the player adds later, so it reads the
|
|
// response before the player can hand its buffer on (a player that moves fragments to a worker
|
|
// empties the buffer it moves).
|
|
const XHR0 = win.XMLHttpRequest;
|
|
if (XHR0) {
|
|
win.XMLHttpRequest = class extends XHR0 {
|
|
constructor(...a) {
|
|
super(...a);
|
|
this.addEventListener("readystatechange", () => {
|
|
if (this.readyState !== 4) return;
|
|
const url = this.responseURL;
|
|
if (!url || !relevant(url, this.getResponseHeader("content-type"))) return;
|
|
let bytes = null;
|
|
if (this.responseType === "arraybuffer" && this.response) bytes = new Uint8Array(this.response).slice();
|
|
else if (this.responseType === "" || this.responseType === "text") bytes = enc.encode(this.responseText);
|
|
if (bytes) handle(url, bytes, this.status, (h) => this.getResponseHeader(h)).catch(() => {});
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
return {
|
|
restore() {
|
|
win.fetch = fetch0;
|
|
if (XHR0) win.XMLHttpRequest = XHR0;
|
|
},
|
|
};
|
|
}
|