// Tests for the fact-check chrome: the claim and its validation, the settings // (factcheck.mjs, through validateChrome), the stamp on the last segment of // each claim, the tally's steps, the deck QR's "original" links, the stamp // page (chrome-stamp.mjs), compose-chrome's stamp region and its overlay. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { claimOf, FACTCHECK_DEFAULTS, FACTCHECK_LIMITS, normalizeClaim, originalUrlAt, resolveFactcheck, roundStamps, STAMP_MOTION, stampSchedule, tallyCues, tallyOf, tallySteps, tallyVerdicts, validateClaims, validateFactcheck, VERDICTS, } from "./factcheck.mjs"; import { VERDICT_DEFAULTS, VERDICT_LABEL_MAX, VERDICTS as COMMON_VERDICTS, } from "yt-dlp-transcript-common/lib/report/verdicts.mjs"; import { deckGeometry, deckLayout, deckQrUrl, deckSchedule, DECK_DEFAULTS, estimateSchedule, feedGeometry, stampGeometry, validateChrome, } from "./deck.mjs"; import { deckCues, deckHtml } from "./chrome-deck.mjs"; import { STAMP_POSE, stampCues, stampHtml } from "./chrome-stamp.mjs"; import { chromeOverlayChain, chromeRegions, stampRegion } from "./build-video.mjs"; const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; const CHROME = { engine: "hyperframes", layout: "deck", deck: {} }; const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, chrome: CHROME }; const PROV = { siteOrigin: "https://example.test", channelSlug: "demo-channel" }; const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-3, `${msg}: ${a} != ${b}`); // k1 is argued over two clips; k2 is a card; c3 carries none; k3 a still. const ENTRIES = [ { type: "clip", id: "c1", video: "abc123", start: 10, end: 20, claim: { id: "k1", verdict: "PARTLY" } }, { type: "clip", id: "c2", video: "abc123", start: 30, end: 40, claim: { id: "k1", verdict: "PARTLY" } }, { type: "card", id: "t1", seconds: 5, heading: "A card", claim: { id: "k2", verdict: "CONTRADICTED" } }, { type: "clip", id: "c3", video: "def456", start: 50, end: 58 }, { type: "image", id: "i1", seconds: 4, src: "a.png", claim: { id: "k3", verdict: "PARTLY" } }, ]; const DURS = [10, 10, 5, 8, 4]; const sched = (render = RENDER, entries = ENTRIES, D = 0.5, metas = []) => deckSchedule({ entries, durs: DURS, D, render, provenance: PROV, metas }); // ---- the claim ---------------------------------------------------------------- test("the verdict vocabulary is common's, one copy: the same objects, not equal copies", () => { assert.equal(VERDICTS, COMMON_VERDICTS); assert.equal(FACTCHECK_DEFAULTS.verdicts, VERDICT_DEFAULTS); assert.deepEqual(Object.keys(FACTCHECK_DEFAULTS.verdicts), [...VERDICTS]); assert.equal(FACTCHECK_LIMITS.label, VERDICT_LABEL_MAX); }); test("normalizeClaim: trims the id, refuses a bad shape, an unknown field, a verdict off the list", () => { assert.equal(normalizeClaim(undefined), null); assert.equal(normalizeClaim(null), null); assert.deepEqual(normalizeClaim({ id: " k1 ", verdict: "NOT_FOUND" }), { id: "k1", verdict: "NOT_FOUND" }); assert.throws(() => normalizeClaim("k1"), /claim must be \{ id, verdict \}/); assert.throws(() => normalizeClaim({ id: "k1", verdict: "PARTLY", note: "x" }), /claim\.note is not a claim field/); assert.throws(() => normalizeClaim({ id: "", verdict: "PARTLY" }), /claim\.id must be/); assert.throws(() => normalizeClaim({ id: "a b", verdict: "PARTLY" }), /claim\.id must be/); assert.throws(() => normalizeClaim({ id: "k1", verdict: "partly" }), /claim\.verdict must be one of CORROBORATED/); assert.deepEqual(VERDICTS, ["CORROBORATED", "PARTLY", "CONTRADICTED", "NOT_FOUND", "UNTESTABLE"]); }); test("claimOf: a sound claim, never a teaser's, never a throw", () => { assert.deepEqual(claimOf(ENTRIES[0]), { id: "k1", verdict: "PARTLY" }); assert.equal(claimOf(ENTRIES[3]), null); assert.equal(claimOf({ type: "clip", claim: { id: "k1" } }), null); assert.equal(claimOf({ type: "teaser", claim: { id: "k1", verdict: "PARTLY" } }), null); }); test("validateClaims: shape, never on a teaser, one verdict per claim id; the review verdict is untouched", () => { assert.deepEqual(validateClaims({ timeline: ENTRIES }), []); // `verdict` is the clip review's field, not the claim's: it is not read here. assert.deepEqual(validateClaims({ timeline: [{ ...ENTRIES[0], verdict: "incorrect" }] }), []); const errs = validateClaims({ timeline: [ ENTRIES[0], { ...ENTRIES[1], claim: { id: "k1", verdict: "CONTRADICTED" } }, { type: "teaser", id: "fin", lines: ["x"], claim: { id: "k9", verdict: "PARTLY" } }, { type: "clip", id: "c9", claim: { id: "k8", verdict: "MAYBE" } }, ], }); assert.equal(errs.length, 3, errs.join("\n")); assert.match(errs[0], /timeline\[1\] \(c2\)\.claim k1 is CONTRADICTED here and PARTLY at timeline\[0\] \(c1\) -- one claim, one verdict/); assert.match(errs[1], /timeline\[2\] \(fin\)\.claim: a teaser is not evidence/); assert.match(errs[2], /timeline\[3\] \(c9\)\.claim\.verdict must be one of/); }); // ---- the settings --------------------------------------------------------------- test("resolveFactcheck: every default, a verdict's label and colour filled on their own", () => { assert.deepEqual(resolveFactcheck(RENDER), { verdicts: Object.fromEntries(VERDICTS.map((v) => [v, { ...FACTCHECK_DEFAULTS.verdicts[v] }])), stamp: { seconds: 3, position: "top-left" }, tally: { show: true, position: "right" }, }); const f = resolveFactcheck({ chrome: { ...CHROME, factcheck: { verdicts: { PARTLY: { label: "Half true" } }, stamp: { seconds: 5 } } } }); assert.deepEqual(f.verdicts.PARTLY, { label: "Half true", color: FACTCHECK_DEFAULTS.verdicts.PARTLY.color }); assert.deepEqual(f.stamp, { seconds: 5, position: "top-left" }); }); test("validateChrome refuses a bad factcheck block in sentences, unknown keys included", () => { assert.deepEqual(validateChrome({ ...CHROME, factcheck: {} }, RENDER), []); assert.deepEqual(validateChrome({ ...CHROME, factcheck: { verdicts: { CORROBORATED: { label: "Holds up", color: "#0a0" } }, stamp: { seconds: 2.5, position: "center" }, tally: { show: false, position: "left" }, }, }, RENDER), []); const errs = validateChrome({ ...CHROME, factcheck: { colour: 1, verdicts: { MAYBE: {}, PARTLY: { color: "orange", label: "x".repeat(25), size: 2 }, NOT_FOUND: "grey" }, stamp: { seconds: 0, position: "middle", spin: true }, tally: { show: "yes", position: "top" }, }, }, RENDER); for (const want of [ /render\.chrome\.factcheck\.colour is not a factcheck setting/, /render\.chrome\.factcheck\.verdicts\.MAYBE is not a verdict/, /render\.chrome\.factcheck\.verdicts\.PARTLY\.size is not a verdict setting/, /render\.chrome\.factcheck\.verdicts\.PARTLY\.label is 25 characters \(at most 24\)/, /render\.chrome\.factcheck\.verdicts\.PARTLY\.color must be a hex colour/, /render\.chrome\.factcheck\.verdicts\.NOT_FOUND must be \{ label, color \}/, /render\.chrome\.factcheck\.stamp\.seconds must be from 1 to 10/, /render\.chrome\.factcheck\.stamp\.position must be "top-left"/, /render\.chrome\.factcheck\.stamp\.spin is not a factcheck setting/, /render\.chrome\.factcheck\.tally\.show must be true or false/, /render\.chrome\.factcheck\.tally\.position must be "right" or "left"/, ]) assert.ok(errs.some((e) => want.test(e)), `${want} in\n${errs.join("\n")}`); assert.deepEqual(validateFactcheck("on"), ["render.chrome.factcheck must be an object"]); assert.match(validateChrome({ ...CHROME, factcheks: {} }, RENDER).join(), /render\.chrome\.factcheks is not a deck setting/); }); // ---- the stamps ------------------------------------------------------------------- test("stampSchedule: once per claim, on its LAST segment, `seconds` before that segment's transition out", () => { const s = sched(); // Starts: c1 0, c2 9.5, t1 19, c3 23.5, i1 31; total 35. assert.deepEqual(s.segments.map((x) => x.start), [0, 9.5, 19, 23.5, 31]); assert.equal(s.total, 35); const [k1, k2, k3] = s.factcheck.stamps; assert.equal(s.factcheck.stamps.length, 3); // k1 lands on c2, not c1; it leaves with the dissolve into t1 (19 → 19.5). assert.deepEqual(k1, { claim: "k1", verdict: "PARTLY", segment: "c2", at: 16, landed: 16.28, out: [19, 19.5] }); assert.deepEqual(k2, { claim: "k2", verdict: "CONTRADICTED", segment: "t1", at: 20.5, landed: 20.78, out: [23.5, 24] }); // The last segment's leaves over the cut's last 0.3 s. assert.deepEqual(k3, { claim: "k3", verdict: "PARTLY", segment: "i1", at: 31.7, landed: 31.98, out: [34.7, 35] }); // The segments carry their claim; the one without has no key. assert.deepEqual(s.segments.map((x) => x.claim?.id ?? null), ["k1", "k1", "k2", null, "k3"]); assert.equal("claim" in s.segments[3], false); }); test("stampSchedule: a short segment stamps after its incoming dissolve; a hard cut leaves 0.3 s before the cut", () => { const short = roundStamps(stampSchedule({ segments: [{ id: "a", start: 0, duration: 5 }, { id: "b", start: 4.5, duration: 2, claim: { id: "k", verdict: "UNTESTABLE" } }, { id: "c", start: 6, duration: 5 }], D: 0.5, total: 11, render: RENDER, })); assert.deepEqual(short, [{ claim: "k", verdict: "UNTESTABLE", segment: "b", at: 5, landed: 5.28, out: [6, 6.5] }]); const hard = roundStamps(stampSchedule({ segments: [{ id: "a", start: 0, duration: 10, claim: { id: "k", verdict: "CORROBORATED" } }, { id: "b", start: 10, duration: 5 }], D: 0, total: 15, render: { ...RENDER, chrome: { ...CHROME, factcheck: { stamp: { seconds: 5 } } } }, })); assert.deepEqual(hard, [{ claim: "k", verdict: "CORROBORATED", segment: "a", at: 4.7, landed: 4.98, out: [9.7, 10] }]); }); test("a cut without claims writes the schedule it always did", () => { const plain = ENTRIES.map(({ claim: _c, ...e }) => e); const s = sched(RENDER, plain); assert.equal("factcheck" in s, false); assert.ok(s.segments.every((x) => !("claim" in x))); assert.equal(tallyOf(s, RENDER), null); // And its deck lays out exactly as before. assert.deepEqual(deckLayout(RENDER, { tally: tallyOf(s, RENDER) }), deckLayout(RENDER)); }); test("the estimate stamps too, from the manifest alone", () => { const e = estimateSchedule({ render: RENDER, provenance: PROV, timeline: ENTRIES }); assert.equal(e.estimated, true); assert.deepEqual(e.factcheck.stamps.map((x) => [x.claim, x.segment]), [["k1", "c2"], ["k2", "t1"], ["k3", "i1"]]); }); // ---- the tally --------------------------------------------------------------------- test("tallySteps: each verdict counts up as its stamps land, in landing order", () => { const stamps = sched().factcheck.stamps; assert.deepEqual(tallyVerdicts(stamps), ["PARTLY", "CONTRADICTED"]); const steps = tallySteps(stamps); assert.deepEqual(steps.map((s) => [s.at, s.verdict, s.count]), [ [16.28, "PARTLY", 1], [20.78, "CONTRADICTED", 1], [31.98, "PARTLY", 2], ]); assert.deepEqual(steps.at(-1).counts, { CORROBORATED: 0, PARTLY: 2, CONTRADICTED: 1, NOT_FOUND: 0, UNTESTABLE: 0 }); }); test("tallyCues: a number per count, each rolling in as its stamp lands and out at the next", () => { const stamps = sched().factcheck.stamps; const { init, events } = tallyCues(stamps); assert.deepEqual(Object.keys(init).filter((k) => /\.n\d+$/.test(k)).sort(), [ "ty.CONTRADICTED.n0", "ty.CONTRADICTED.n1", "ty.PARTLY.n0", "ty.PARTLY.n1", "ty.PARTLY.n2", ]); assert.deepEqual(init["ty.PARTLY.n0"], { yPercent: 0, autoAlpha: 1 }); assert.deepEqual(init["ty.PARTLY.n2"], { yPercent: 100, autoAlpha: 0 }); const inAt = (k) => events.find((e) => e.k === k && e.to.autoAlpha === 1)?.at; const outAt = (k) => events.find((e) => e.k === k && e.to.autoAlpha === 0)?.at; assert.equal(inAt("ty.PARTLY.n1"), 16.28); assert.equal(outAt("ty.PARTLY.n1"), 31.98); assert.equal(inAt("ty.PARTLY.n2"), 31.98); assert.equal(inAt("ty.CONTRADICTED.n1"), 20.78); // A cell lights at its first count only. assert.deepEqual(events.filter((e) => e.k === "ty.PARTLY").map((e) => e.at), [16.28]); // The flash fires at every step. assert.equal(events.filter((e) => e.k.endsWith(".flash") && e.to.opacity === 1).length, 3); }); test("the deck draws the tally: cells for the verdicts the cut uses, the text column narrowed, cues with stated froms", () => { const s = sched(); const tally = tallyOf(s, RENDER); const lay = deckLayout(RENDER, { tally }); // Right of the text, left of the QR's cell (its plate starts 46 px before the code). assert.deepEqual(lay.tally, { x: 1394, y: 47, width: 258, height: 118, cell: 124, gap: 10 }); assert.equal(lay.text.x + lay.text.width, 1394 - 40); assert.ok(lay.tally.x + lay.tally.width <= lay.qr.x - 46); const left = deckLayout({ ...RENDER, chrome: { ...CHROME, factcheck: { tally: { position: "left" } } } }, { tally }); assert.equal(left.tally.x, 48); assert.equal(left.text.x, 48 + 258 + 40); const html = deckHtml(s, RENDER, {}); assert.match(html, /class="tally"/); assert.equal((html.match(/class="tcell"/g) ?? []).length, 2); assert.match(html, /data-verdict="PARTLY"[^>]*data-k="ty\.PARTLY"/); assert.match(html, />Partly trueContradicted [k, { ...v }])); for (const c of cues) { if (!c.k.startsWith("ty.")) continue; for (const p of Object.keys(c.to)) assert.deepEqual(c.from[p], state[c.k][p], `${c.k}.${p} at ${c.at}`); Object.assign(state[c.k], c.to); } // tally.show false: no tally, the deck as it always was. const off = { ...RENDER, chrome: { ...CHROME, factcheck: { tally: { show: false } } } }; assert.doesNotMatch(deckHtml(s, off, {}), /class="tally"/); assert.ok(deckCues(s, off).cues.every((c) => !c.k.startsWith("ty."))); }); // ---- the stamp page -------------------------------------------------------------- test("stampGeometry: a box in the footage at its position; the feed's box for a feed cut", () => { const f = deckGeometry(RENDER).footage; assert.deepEqual(stampGeometry(RENDER), { x: f.x + 28, y: f.y + 28, width: 560, height: 200 }); const br = stampGeometry({ ...RENDER, chrome: { ...CHROME, factcheck: { stamp: { position: "bottom-right" } } } }); assert.deepEqual(br, { x: f.x + f.width - 28 - 560, y: f.y + f.height - 28 - 200, width: 560, height: 200 }); const feed = { ...RENDER, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } }; const ff = feedGeometry(feed).footage; assert.deepEqual(stampGeometry(feed, { feed: true }), { x: ff.x + 28, y: ff.y + 28, width: 560, height: 200 }); }); test("stampCues: each stamp slams in to land at its `landed`, flashes, and leaves over its `out`", () => { const s = sched(); const { init, cues } = stampCues(s); assert.deepEqual(init.st0, { autoAlpha: 0, scale: STAMP_POSE.scale, rotation: STAMP_POSE.from }); const of = (k) => cues.filter((c) => c.k === k); const [slam, leave] = of("st0"); near(slam.at, 16, "slam starts"); near(slam.at + slam.dur, 16.28, "lands"); assert.deepEqual(slam.to, { autoAlpha: 1, scale: 1, rotation: STAMP_POSE.rest }); near(leave.at, 19, "leaves"); near(leave.dur, 0.5, "over the dissolve"); assert.deepEqual(leave.from, { autoAlpha: 1, scale: 1 }); near(of("fl0")[0].at, 16.28, "flash at landing"); near(of("fl0")[1].at, 16.28 + STAMP_MOTION.flashUp, "flash down"); assert.equal(cues.filter((c) => c.k.startsWith("st")).length, 6); }); test("stampHtml: a stamp per claim in its verdict's words and colour; escaped; the composition contract", () => { const entries = ENTRIES.map((e, i) => (i === 2 ? { ...e, claim: { id: "k2", verdict: "CONTRADICTED" } } : e)); const render = { ...RENDER, chrome: { ...CHROME, factcheck: { verdicts: { PARTLY: { label: '"half"', color: "#123456" } } } } }; const s = sched(render, entries); const html = stampHtml(s, render, { fonts: { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" } }); assert.equal((html.match(/class="stamp"/g) ?? []).length, 3); assert.match(html, /data-claim="k1" data-verdict="PARTLY" data-k="st0" style="--vc:#123456;/); assert.ok(html.includes("<b>"half"</b>")); assert.match(html, /CONTRADICTED[^]*>Contradicted/g) ?? []).length, 3, "gsap, data, runtime -- and no fourth"); assert.doesNotMatch(html, /https?:\/\//, "nothing leaves the machine"); const win = stampHtml(s, render, { from: 15, duration: 5 }); assert.match(win, /"window":\{"from":15,"dur":5\}/); assert.throws(() => stampHtml(sched(RENDER, ENTRIES.map(({ claim: _c, ...e }) => e)), RENDER), /stamps no claim/); }); test("the stamps' overlay is laid like the deck's: whole cut, reinit off, rgba, shortest, at stampGeometry", () => { const s = sched(); const region = stampRegion(RENDER, "/o/chrome/stamp-frames", s); assert.deepEqual(region, { name: "stamp", frames: "/o/chrome/stamp-frames", ...stampGeometry(RENDER) }); const hf = chromeOverlayChain(RENDER, [...chromeRegions(RENDER, "/o"), region], "[v]", 3); assert.deepEqual(hf.inputs.slice(8), [ "-reinit_filter", "0", "-framerate", "30", "-start_number", "1", "-i", "/o/chrome/stamp-frames/frame_%06d.png", ]); const { x, y } = stampGeometry(RENDER); assert.match(hf.chain, new RegExp(`\\[4:v\\]format=rgba\\[hfa1\\];\\[hf0\\]\\[hfa1\\]overlay=x=${x}:y=${y}:format=yuv444:shortest=1\\[hf1\\]`)); }); // ---- the deck QR's "original" links ------------------------------------------------ test("originalUrlAt: YouTube at the second; Rumble and X as they are; nothing for no link", () => { assert.equal(originalUrlAt("https://www.youtube.com/watch?v=abc123", 51.9), "https://www.youtube.com/watch?v=abc123&t=51"); assert.equal(originalUrlAt("https://www.youtube.com/watch?v=abc123&t=9", 51), "https://www.youtube.com/watch?v=abc123&t=51"); assert.equal(originalUrlAt("https://youtu.be/abc123", 7), "https://youtu.be/abc123?t=7"); assert.equal(originalUrlAt("https://rumble.com/v1abc-demo-video.html", 51), "https://rumble.com/v1abc-demo-video.html"); assert.equal(originalUrlAt("https://x.com/demo/status/1234567890", 51), "https://x.com/demo/status/1234567890"); assert.equal(originalUrlAt(undefined, 5), null); assert.equal(originalUrlAt("not a url", 5), null); assert.equal(originalUrlAt("javascript:alert(1)", 5), null); }); test("deckQrUrl: qr.links \"original\" links the record's page; citeUrl still wins; no record falls back to the site", () => { assert.equal(DECK_DEFAULTS.qr.links, "site"); const clip = { type: "clip", id: "c1", video: "abc123", start: 51.4, end: 60 }; const yt = { webpageUrl: "https://www.youtube.com/watch?v=abc123" }; const site = "https://example.test/?v=demo-channel%2Fabc123&t=51"; assert.equal(deckQrUrl(clip, PROV), site); assert.equal(deckQrUrl(clip, PROV, { meta: yt }), site, "site is the default"); assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: yt }), "https://www.youtube.com/watch?v=abc123&t=51"); assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: { webpageUrl: "https://rumble.com/v1abc-demo.html" } }), "https://rumble.com/v1abc-demo.html"); assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: { webpageUrl: "https://x.com/demo/status/42" } }), "https://x.com/demo/status/42"); assert.equal(deckQrUrl({ ...clip, citeUrl: "https://example.test/cited" }, PROV, { links: "original", meta: yt }), "https://example.test/cited"); assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: null }), site); // The schedule threads the setting and each clip's record through. const render = { ...RENDER, chrome: { ...CHROME, deck: { qr: { links: "original" } } } }; const s = deckSchedule({ entries: [clip], durs: [8.6], D: 0.5, render, provenance: PROV, metas: [yt], }); assert.equal(s.segments[0].qrUrl, "https://www.youtube.com/watch?v=abc123&t=51"); assert.match(validateChrome({ ...CHROME, deck: { qr: { links: "platform" } } }, RENDER).join(), /qr\.links must be "site" or "original"/); }); // ---- compose-chrome's stamp region, with the renderer stubbed ---------------------- test("compose-chrome: the stamp region composes, renders through the stub and caches by its key", async () => { const dir = mkdtempSync(path.join(tmpdir(), "stamp-compose-")); const env = process.env.HYPERFRAMES_BIN; try { const stub = path.join(dir, "hf-stub.mjs"); writeFileSync(stub, `#!/usr/bin/env node import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; const a = process.argv.slice(2); const out = a[a.indexOf("--output") + 1]; const fps = Number(a[a.indexOf("--fps") + 1]); const html = readFileSync(a.at(-1) + "/index.html", "utf8"); const dur = Number(/data-composition-id="[^"]*"[^>]*data-duration="([\\d.]+)"/.exec(html)[1]); mkdirSync(out, { recursive: true }); for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(out + "/frame_" + String(i).padStart(6, "0") + ".png", "x"); `); chmodSync(stub, 0o755); process.env.HYPERFRAMES_BIN = stub; // Any file serves as a face here: the page only names it. const font = path.join(dir, "face.ttf"); writeFileSync(font, "face"); const s = sched(); const mp = path.join(dir, "video.manifest.json"); writeFileSync(mp, JSON.stringify({ slug: "f", render: { ...RENDER, fontRegular: font, fontBold: font }, timeline: ENTRIES })); const out = path.join(dir, "out", "sourced"); mkdirSync(out, { recursive: true }); writeFileSync(path.join(out, "schedule.json"), JSON.stringify(s)); const { composeChrome } = await import("./compose-chrome.mjs"); const r = await composeChrome({ manifestPath: mp, region: "stamp", doRender: true, fps: 30 }); assert.equal(r.projDir, path.join(out, "chrome", "stamp")); assert.equal(r.frames, path.join(out, "chrome", "stamp-frames")); assert.equal(r.frameCount, Math.round(s.total * 30)); assert.equal(r.cached, false); assert.match(readFileSync(path.join(r.projDir, "index.html"), "utf8"), /data-composition-id="stamp"/); assert.equal((await composeChrome({ manifestPath: mp, region: "stamp", doRender: true, fps: 30 })).cached, true); assert.equal((await composeChrome({ manifestPath: mp, region: "stamp", preview: true })).projDir, path.join(out, "chrome", "stamp-preview")); assert.equal((await composeChrome({ manifestPath: mp, region: "stamp", from: 9, duration: 4 })).projDir, path.join(out, "chrome", "stamp-from9")); // A schedule with nothing stamped has no stamp region. writeFileSync(path.join(out, "schedule.json"), JSON.stringify(sched(RENDER, ENTRIES.map(({ claim: _c, ...e }) => e)))); await assert.rejects(composeChrome({ manifestPath: mp, region: "stamp" }), /needs a schedule that stamps a claim/); } finally { if (env === undefined) delete process.env.HYPERFRAMES_BIN; else process.env.HYPERFRAMES_BIN = env; rmSync(dir, { recursive: true, force: true }); } });