open-standards/wai/wai-audit/web/adapter-tap.js
Transaction Science 4627e02e04 wai-audit: the browser end to end, and what it found
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]>
2026-10-25 21:50:20 -04:00

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;
},
};
}