// The reference producer of stitched presentation claims (wai/extensions/stitched-presentation.md // ยง8), against the corpus wai/live-conformance/stitched/ (`node --test web/ads/stitched.test.mjs`): // markers read from the corpus's own manifests, observation sequences recorded into spans, the // ad's region measured in a composited frame, and the claims written and signed from them, byte // for byte as the corpus holds them. import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import * as ap from "./presentation.js"; import * as st from "./stitched.js"; const DIR = new URL("../../../../../../wai/live-conformance/stitched/", import.meta.url); const file = (n) => readFileSync(new URL(n, DIR), "utf8"); const doc = JSON.parse(file("vectors.json")); const vector = (name) => doc.vectors.find((v) => v.name === name); const hex = (s) => Uint8Array.from(s.match(/../g) ?? [], (h) => parseInt(h, 16)); const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const creative = (k) => doc.creatives[k].objects.map((o) => ({ contentHash: hex(o.content_hash), durationMs: o.duration_ms })); /** Session key `i` of the corpus: SHA-256 of its label and `i` (its README). */ const key = async (i) => ap.sessionKeyFromSeed(await ap.sha256(new TextEncoder().encode("wai:stitched-presentation/corpus/player\x00"), Uint8Array.of(i))); /** Observations every 100 ms of the ad's media time from `from` to `to`, at normal speed. */ function play(rec, from, to, visibleBp, audible = true) { for (let p = from; p <= to; p += 100) rec.observe({ positionMs: p, playing: true, visibleBp, audible }); } function ended(rec, at, visibleBp) { rec.observe({ positionMs: at, playing: false, visibleBp, audible: true }); return rec.spans(); } async function claim({ player, found, manifest, objects, spans, geometry = ap.GEOMETRY.AD, shareBp = 10_000, attestation }) { const k = await key(player); const c = { marker: found.marker, adId: found.adId, manifest: await st.manifestDigest(manifest), session: await st.sessionOf(k.publicKey), creativeHash: await st.creativeDigest(objects), player: k.publicKey, geometry, shareBp, frames: st.NOT_EXPOSED, objects: st.touched(objects, spans), spans, attestation, }; return { hex: toHex(await st.signClaim(c, k.privateKey)), claim: c }; } test("a stitched ad under a date range: the marker read from the playlist, the corpus's claim", async () => { const playlist = file("hls_stitched.viewer1.m3u8"); const ads = st.hlsAds(playlist, "X-AD-ID"); assert.deepEqual(ads.map((a) => a.adId), ["srv-v1-0", "srv-v1-1"], "the date ranges that carry an ad identity, and not the break's own"); const rec = new ap.SpanRecorder(); play(rec, 0, 15_000, 10_000); const spans = ended(rec, 15_000, 10_000); const got = await claim({ player: 1, found: ads[0], manifest: playlist, objects: creative(0), spans }); assert.equal(got.hex, vector("hls_stitched").claims[0]); }); test("scrolled partly out of view to the third quartile: a span at each change", async () => { const playlist = file("hls_stitched.viewer1.m3u8"); const rec = new ap.SpanRecorder(); play(rec, 0, 3_900, 10_000); play(rec, 4_000, 7_900, 6_000); play(rec, 8_000, 11_250, 3_000); rec.observe({ positionMs: 11_250, playing: true, visibleBp: 3_000, audible: true }); const spans = ended(rec, 11_250, 3_000); assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.visibleBp]), [[0, 4_000, 10_000], [4_000, 4_000, 6_000], [8_000, 3_250, 3_000]]); const got = await claim({ player: 1, found: st.hlsAds(playlist, "X-AD-ID")[1], manifest: playlist, objects: creative(1), spans }); assert.equal(got.hex, vector("hls_stitched").claims[1]); }); test("an interstitial's asset: its date range from the playlist, its identity from the asset list", async () => { const v = vector("hls_interstitial"); const [dr] = st.interstitials(file("hls_interstitial.m3u8")); assert.equal(dr.id, "break-300"); const list = file("hls_interstitial.assets.json"); const ads = st.interstitialAds(dr, list); const want = hex(v.claims[0]); // The token is the issuer's (the corpus signs it with RFC 9578's test key); the claim carries // it as the long field before its signature. const tokenLen = 98 + 256; const token = want.subarray(want.length - 64 - tokenLen, want.length - 64); const rec = new ap.SpanRecorder(); play(rec, 0, 15_000, 10_000); const spans = ended(rec, 15_000, 10_000); const got = await claim({ player: 11, found: ads[0], manifest: list, objects: creative(3), spans, geometry: ap.GEOMETRY.SCREEN, attestation: token }); assert.equal(got.hex, v.claims[0]); const challenge = ap.tokenChallenge(2, doc.issuers[0].name, await st.redemptionContext(got.claim)); assert.equal(toHex(token.subarray(34, 66)), toHex(await ap.sha256(challenge)), "the token answers this presentation's own challenge"); }); test("a squeeze: the ad's region from the frame, 70% of it in view, the corpus's claim", async () => { const mpd = file("squeezeback.mpd"); const [found] = st.mpdAds(mpd, "messageData"); assert.equal(found.adId, "srv-sq-1"); const frame = { kind: 3, width: 1920, height: 1080, corner: 4, windowW: 1440, windowH: 810, gap: 0, aspect: 0 }; const { region } = st.adFrame(frame); assert.deepEqual(region, [[0, 0, 1920, 270], [0, 270, 480, 810]]); assert.equal(st.shareBp(frame), 4375); // The picture drawn at its own size, and the viewport cut 513 pixels down: the whole top band // and 243 of the side band's 810 rows are in view, 635 040 of 907 200 pixels. const displayed = { x: 0, y: 0, width: 1920, height: 1080 }; const vis = st.regionVisibleBp(region, 1920, 1080, displayed, { x: 0, y: 0, width: 1920, height: 513 }); assert.equal(vis, 7_000); assert.equal(st.regionVisibleBp(null, 1920, 1080, displayed, { x: 0, y: 0, width: 1920, height: 513 }), 4_750, "the whole picture, not the region"); const rec = new ap.SpanRecorder(); play(rec, 0, 20_000, vis); const spans = ended(rec, 20_000, vis); const got = await claim({ player: 22, found, manifest: mpd, objects: creative(5), spans, shareBp: st.shareBp(frame) }); assert.equal(got.hex, vector("squeezeback").claims[1]); }); test("a double box: the ad's box, and its share", async () => { const frame = { kind: 4, width: 1920, height: 1080, corner: 1, windowW: 0, windowH: 0, gap: 16, aspect: 1 }; assert.deepEqual(st.adFrame(frame).ad, [968, 272, 952, 535]); assert.equal(st.shareBp(frame), 2456); const playlist = file("double_box.m3u8"); const [found] = st.hlsAds(playlist, "X-AD-ID"); const rec = new ap.SpanRecorder(); play(rec, 0, 10_000, 10_000); const got = await claim({ player: 31, found, manifest: playlist, objects: creative(6), spans: ended(rec, 10_000, 10_000), shareBp: st.shareBp(frame) }); assert.equal(got.hex, vector("double_box").claims[0]); }); test("the readers refuse what the subset and the attribute list leave out", () => { assert.equal(st.readXml(""), null); assert.equal(st.readXml(''), null); assert.equal(st.readXml(" "), null); assert.deepEqual(st.readXml("").children, [" b "]); assert.equal(st.attributeList('ID="a",,X=1'), null); assert.equal(st.attributeList("ID=a b"), null); assert.equal(st.hlsAds("not a playlist", "X-AD-ID"), null); });