open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs
Transaction Science 760e5c343d wai: register wai.live.ad_presentation; a reference producer in the browser client; CI
The registry lists the record and the WPC1 magic (the export regenerated), SPEC
§5 describes the claim and the billing rule, and the CHANGELOG records them.
joule-moq-web gains web/ads/presentation.js, a span recorder over a video
element and the claim's writer and signer on WebCrypto's Ed25519, whose node
tests turn observation sequences into the corpus's claims byte for byte; no page
plays ads yet. wai-conformance runs the module's tests, the corpus test,
ad_presentation_verify.py and the producer's tests; joule-moq-web's e2e script
runs the producer's tests with the page's other unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:23:52 -04:00

95 lines
5 KiB
JavaScript

// The reference producer of ad-presentation claims (wai/extensions/ad-presentation.md §10),
// against the corpus wai/live-conformance/presentation/ (`node --test web/ads/presentation.test.mjs`):
// observation sequences recorded into spans, 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";
const doc = JSON.parse(readFileSync(new URL("../../../../../../wai/live-conformance/presentation/vectors.json", import.meta.url), "utf8"));
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:ad-presentation/corpus/player\x00"), Uint8Array.of(i)));
/** Observations every 100 ms of media time from `from` to `to`, playing 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 });
}
async function claimFrom(vectorName, i, slot, objects, spans, geometry = ap.GEOMETRY.AD) {
const v = vector(vectorName);
const k = await key(i);
const room = hex(v.room);
const c = {
room,
cue: hex(v.breaks[0].digest),
session: await ap.sessionOf(room, k.publicKey),
slot,
creativeHash: await ap.creativeDigest(objects),
player: k.publicKey,
geometry,
frames: ap.NOT_EXPOSED,
objects: ap.touched(objects, spans),
spans,
};
return toHex(await ap.signClaim(c, k.privateKey));
}
test("a viewer in view and audible throughout: one span, the corpus's claim", async () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 15_000, 10_000);
rec.observe({ positionMs: 15_000, playing: false, visibleBp: 10_000, audible: true }); // ended
const spans = rec.spans();
assert.deepEqual(spans, [{ startMs: 0, lengthMs: 15_000, visibleBp: 10_000, flags: ap.AUDIBLE }]);
assert.equal(await claimFrom("video_rule", 1, 0, creative(0), spans), vector("video_rule").claims[0]);
});
test("scrolled partly out of view: a span at each change, the least fraction of each", async () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 3_900, 3_000);
play(rec, 4_000, 4_000, 6_000);
play(rec, 4_100, 6_900, 6_050); // within the same whole percent as 60.00%: one span, its least
play(rec, 7_000, 12_000, 2_000);
rec.observe({ positionMs: 12_000, playing: false, visibleBp: 2_000, audible: true });
const spans = rec.spans();
assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.visibleBp, s.flags]), [[0, 4_000, 3_000, 1], [4_000, 3_000, 6_000, 1], [7_000, 5_000, 2_000, 1]]);
assert.equal(await claimFrom("video_rule", 1, 1, creative(1), spans), vector("video_rule").claims[1]);
});
test("a pause: the span after it is a resumption, and the run is broken", async () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 1_500, 10_000);
rec.observe({ positionMs: 1_500, playing: false, visibleBp: 10_000, audible: true });
play(rec, 1_500, 2_900, 10_000);
play(rec, 3_000, 4_000, 1_000);
rec.observe({ positionMs: 4_000, playing: false, visibleBp: 1_000, audible: true });
const spans = rec.spans();
assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.flags]), [[0, 1_500, 1], [1_500, 1_500, 3], [3_000, 1_000, 1]]);
assert.equal(await claimFrom("video_rule", 2, 1, creative(1), spans), vector("video_rule").claims[3]);
});
test("a seek back states no media time twice, and play past it resumes", () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 5_000, 10_000);
rec.observe({ positionMs: 2_000, playing: true, visibleBp: 10_000, audible: true, seeked: true });
play(rec, 2_100, 6_000, 10_000);
const spans = rec.spans();
assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.flags]), [[0, 5_000, 1], [5_000, 1_000, 3]]);
});
test("the attestation challenge is the corpus's: an attested claim's token answers it", async () => {
const v = vector("cross_media_attested");
const b = hex(v.claims[0]);
// The claim's fields at their offsets (§5.1), and its token: the long field before the signature.
const c = { room: b.subarray(5, 37), cue: b.subarray(37, 69), session: b.subarray(69, 85), slot: b[85], creativeHash: b.subarray(86, 118), player: b.subarray(118, 150) };
const tokenLen = 98 + 256;
const token = b.subarray(b.length - 64 - tokenLen, b.length - 64);
const challenge = ap.tokenChallenge(2, doc.issuers[0].name, await ap.redemptionContext(c));
assert.equal(toHex(token.subarray(34, 66)), toHex(await ap.sha256(challenge)));
assert.equal(toHex(await ap.sessionOf(c.room, c.player)), toHex(c.session));
});