import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { expect, test } from "./helpers"; import type { Locator, Page } from "@playwright/test"; import { reportChangeSummary, type ReportHistoryView } from "yt-dlp-transcript-common/lib/report/revisions"; import { historyGitEnv } from "yt-dlp-transcript-common/publish/reportHistory"; import { readFixtureReport } from "../fixtures/report-site/fixture"; // A cited site, as a reader meets it: its one report as the home page, the // report (verdicts, tally, the source's sentence, numbered inline citations // with previews, the reference list), the moment pages each citation opens, // and the shell's pages that say this site publishes its reports only. Every // test also holds the page's own requests to the cited promise (helpers.ts). // // The site is export/fixtures/report-site, staged and built by // e2e-report/stage.ts: one fact-check citing a video span (c01), an audio // span (au1), a post (p01), the article's own sentences (a01, a02) and a web // page (w01). It has two revisions (report.v1.json, then report.json), and // publishes their history. const REPORT = "/reports/demo-factcheck/"; const VIDEO_MOMENT = "/m/demo-channel/abc123/3126.00-3151.00/"; const AUDIO_MOMENT = "/m/demo-podcast/ep-042/610.50-628.00/"; const POST_MOMENT = "/m/demo-social/1234567890/"; const HISTORY = "/reports/demo-factcheck/history/"; const inlineCite = (page: Page, id: string): Locator => page.locator(`[data-inline-cite="${id}"]`).first(); const preview = (page: Page, id: string): Locator => page.locator(`[data-cite-preview="${id}"]`); async function expectImageLoaded(img: Locator): Promise { await expect(img).toBeVisible(); await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0); } test("one report: the home page IS the report, and the header links nothing", async ({ page }) => { await page.goto("/"); // The series on one line, the title on the next, both in the heading. await expect(page.locator("h1")).toHaveText("Demo Checks Checking an example article"); await expect(page.locator("h1 [data-report-series]")).toHaveText("Demo Checks"); // One line of dates and the revision; then the document under review as a // card on its rail: its title linked to it, author · publisher · date, no label. await expect(page.locator("[data-report-dates]")).toHaveText("2026-10-01 · updated 2026-10-04 · revision 2"); const card = page.locator("header [data-subject-source]"); await expect(card.getByRole("link", { name: "An example article about a demo channel" })).toHaveAttribute( "href", "https://example.org/articles/demo", ); await expect(card).toContainText("A. Writer · Example Gazette · 2026-09-20"); await expect(card).not.toContainText(/Under review|The article|Read in the edition/); await expect(card).toHaveCSS("border-left-color", "rgb(138, 111, 176)"); await expect(page.locator("[data-report-attribution], [data-report-byline]")).toHaveCount(0); await expect(page.locator("article[data-claim]")).toHaveCount(5); // The tab names the report, as the report's own page does. await expect(page).toHaveTitle("Demo Checks: Checking an example article — Demo Reports"); await expect(page.locator("[data-report-index]")).toHaveCount(0); await expect(page.locator("header nav a")).toHaveCount(0); }); test("the header at 375 px: the title and the card wrap, nothing scrolls sideways", async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); await page.goto(REPORT); const card = page.locator("header [data-subject-source]"); await expect(card).toBeVisible(); const box = await card.boundingBox(); expect(box!.x + box!.width).toBeLessThanOrEqual(375); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375); }); test("one report: /reports/ is no index, it forwards to the home page", async ({ page }) => { await page.goto("/reports/"); await expect(page).toHaveURL("/"); await expect(page.locator("h1")).toContainText("Checking an example article"); }); test("the report: verdict chips, the tally, the source's sentence as a still, numbered inline citations", async ({ page }) => { await page.goto(REPORT); // The tally names each verdict the report reaches once; each claim carries its chip. const tally = page.locator("[data-verdict-tally]"); await expect(tally).toBeVisible(); await expect(tally.locator("[data-verdict]")).toHaveCount(4); const claims = page.locator("article[data-claim]"); await expect(claims).toHaveCount(5); for (const [claim, verdict] of [ ["claim-1", "CONTRADICTED"], ["claim-2", "PARTLY"], ["claim-3", "CONTRADICTED"], ["claim-4", "UNTESTABLE"], ["claim-5", "CORROBORATED"], ]) { await expect(page.locator(`article#${claim} [data-verdict="${verdict}"]`)).toBeVisible(); } // A claim's flag is a pill on its card; an unflagged claim has none. await expect(page.locator("article#claim-4 [data-claim-flag]")).toHaveText("No source given"); await expect(page.locator("article[data-claim] [data-claim-flag]")).toHaveCount(1); await expect(page).toHaveTitle("Demo Checks: Checking an example article — Demo Reports"); // The claim's sentence in the article, as the reviewed page showed it. await expectImageLoaded(page.locator('[data-source-sentence="a01"] img[src$="/stills/a01.png"]')); // It sits on a rail in the document's colour, as the document's box does, and // does not link back to the box. const sentence = page.locator('[data-source-sentence="a01"]'); await expect(sentence).toHaveCSS("border-left-color", "rgb(138, 111, 176)"); await expect(sentence.locator('a[href^="#source-"]')).toHaveCount(0); await expect(page.locator("#source-s0")).toHaveCSS("border-left-color", "rgb(138, 111, 176)"); // Inline citations carry their number in the report, by first appearance. for (const [id, n] of [ ["c01", "1"], ["au1", "2"], ["w01", "4"], ["a02", "5"], ["p01", "6"], ]) { await expect(inlineCite(page, id).locator("[data-cite-number]")).toHaveText(`[${n}]`); } }); test("three tiers, each marked by its depth; nothing above the claims is folded away", async ({ page }) => { await page.goto(REPORT); const tiers = page.locator("[data-report-tier]"); expect(await tiers.evaluateAll((els) => els.map((e) => e.getAttribute("data-report-tier")))).toEqual(["1", "2", "3"]); for (const depth of [1, 2, 3]) { const marker = page.locator(`[data-report-tier="${depth}"] [data-tier-marker="${depth}"]`); await expect(marker).toBeVisible(); await expect(marker.locator('[data-depth-dot="filled"]')).toHaveCount(depth); await expect(marker).toHaveText(""); } await expect(page.locator('[data-report-tier="1"] details, [data-report-tier="2"] details')).toHaveCount(0); // The quick take ends on where to go next. const jumps = page.locator("[data-report-jumps] a"); await expect(jumps).toHaveText(["What the check found", "Every claim", "Downloads"]); // What the check found: groups in a fixed order, every row shown, each a link to its claim. const found = page.locator('[data-report-tier="2"]'); expect(await found.locator("[data-found-group]").evaluateAll((els) => els.map((e) => e.getAttribute("data-found-group")))).toEqual([ "CONTRADICTED", "PARTLY", "UNTESTABLE", "CORROBORATED", ]); const rows = found.locator("[data-found-row]"); await expect(rows).toHaveCount(5); for (const row of await rows.all()) await expect(row).toBeVisible(); await expect(found.locator('[data-found-row="claim-3"]')).toContainText("He denies ever saying it."); await found.locator('[data-found-row="claim-2"] a').click(); await expect(page).toHaveURL(`${REPORT}#claim-2`); await expect(page.locator("article#claim-2")).toBeInViewport(); // Every claim: how it was checked, plain, at its start. const method = page.locator('[data-report-tier="3"] [data-report-method]'); await expect(method).toBeVisible(); await expect(method).toContainText("How it was checked"); await expect(method.locator("details")).toHaveCount(0); }); test("a claim's evidence: what the report added first and marked, the article's own folded away", async ({ page }) => { await page.goto(REPORT); const claim = page.locator("article#claim-1"); const cards = claim.locator("[data-citation]"); await expect(cards).toHaveCount(3); expect(await cards.evaluateAll((els) => els.map((e) => e.getAttribute("data-citation")))).toEqual(["c01", "au1", "w01"]); // Added: the project's mark alone, under the card's number box and as wide // (the words are its tooltip); nothing else in the claim is marked. const added = claim.locator('[data-citation="c01"] [data-citation-added]'); await expect(added).toHaveAttribute("title", "Not in the article"); const mark = added.locator("svg[data-brand-mark]"); await expect(mark).toBeVisible(); const numberBox = (await added.locator("xpath=preceding-sibling::span[1]").boundingBox())!; const markBox = (await mark.boundingBox())!; expect(Math.abs(markBox.width - numberBox.width)).toBeLessThan(1); expect(Math.abs(markBox.x - numberBox.x)).toBeLessThan(1); expect(markBox.y).toBeGreaterThanOrEqual(numberBox.y + numberBox.height); await expect(claim.locator("[data-citation-added]")).toHaveCount(1); // The flag pill wears the same mark. await expect(page.locator("article#claim-4 [data-claim-flag] svg[data-brand-mark]")).toBeVisible(); // What the article gave itself: folded until asked for. const given = claim.locator("details[data-subject-evidence]"); await expect(given.locator("summary")).toHaveText("In the article (1)"); await expect(given).not.toHaveAttribute("open", /.*/); await expect(given.locator('[data-citation="w01"]')).toBeHidden(); await given.locator("summary").click(); await expect(given.locator('[data-citation="w01"]')).toBeVisible(); await expect(given.locator("[data-citation-added]")).toHaveCount(0); }); test("hovering an inline citation previews it; leaving closes it", async ({ page }) => { await page.goto(REPORT); for (const [id, kind, text] of [ ["c01", "video", "The bridge opened in the spring of twenty nineteen"], ["p01", "post", "Never said I would move. Not once."], ["a02", "source", "He has said many times that he would move away."], ]) { await inlineCite(page, id).hover(); const card = preview(page, id); await expect(card).toBeVisible(); await expect(card.locator(`[data-citation-kind="${kind}"]`)).toBeVisible(); await expect(card).toContainText(text); // Away from the marker and its card, the preview closes. await page.mouse.move(2, 2); await expect(card).toHaveCount(0); } }); test("focusing an inline citation's number previews it; Escape closes it", async ({ page }) => { await page.goto(REPORT); for (const [id, kind] of [ ["c01", "video"], ["p01", "post"], ["a02", "source"], ]) { const number = inlineCite(page, id).locator("[data-cite-number]"); await number.focus(); const card = preview(page, id); await expect(card).toBeVisible(); await expect(card.locator(`[data-citation-kind="${kind}"]`)).toBeVisible(); await expect(number).toHaveAttribute("aria-expanded", "true"); await page.keyboard.press("Escape"); await expect(card).toHaveCount(0); await expect(number).toHaveAttribute("aria-expanded", "false"); } }); test("a post citation's preview shows the captured shot", async ({ page }) => { await page.goto(REPORT); await inlineCite(page, "p01").locator("[data-cite-number]").focus(); await expectImageLoaded(preview(page, "p01").locator('img[src="/media/posts/demo-social/1234567890/shot.png"]')); }); test("a citation's number jumps to its entry in the reference list", async ({ page }) => { await page.goto(REPORT); const refs = page.locator("[data-reference-list] [data-reference]"); await expect(refs).toHaveCount(6); await expect .poll(() => refs.evaluateAll((els) => els.map((el) => el.id))) .toEqual(["c-c01", "c-au1", "c-a01", "c-w01", "c-a02", "c-p01"]); await inlineCite(page, "w01").locator("[data-cite-number]").click(); await expect(page).toHaveURL(`${REPORT}#c-w01`); await expect(page.locator("#c-w01")).toBeInViewport(); await expect(page.locator("#c-w01")).toContainText("Opened to traffic: April 2019."); }); test("the report downloads as HTML, PDF, Markdown, slides and an evidence pack, its citations as JSON and CSV", async ({ page, request, }) => { await page.goto(REPORT); const links = page.locator("[data-citation-downloads] a[download]"); await expect(links).toHaveText(["HTML", "PDF", "Markdown", "Slides", "Slides PDF", "Evidence pack", "Citations JSON", "CSV"]); const hrefs = await links.evaluateAll((els) => els.map((el) => el.getAttribute("href")!)); expect(hrefs).toEqual([ `${REPORT}report.html`, `${REPORT}report.pdf`, `${REPORT}report.md`, `${REPORT}slides.html`, `${REPORT}slides.pdf`, `${REPORT}evidence-pack.zip`, `${REPORT}citations.json`, `${REPORT}citations.csv`, ]); for (const href of hrefs) { const res = await request.get(href); expect(res.status(), href).toBe(200); const body = await res.body(); if (href.endsWith("citations.json")) expect(JSON.parse(body.toString()).format).toBe("archilyzer-citations"); else if (href.endsWith(".csv")) expect(body.toString()).toContain("c01"); else if (href.endsWith("slides.html")) expect(body.toString()).toContain("Demo Checks: Checking an example article — slides"); else if (href.endsWith(".html")) expect(body.toString()).toContain("Demo Checks: Checking an example article"); else if (href.endsWith(".pdf")) expect(body.subarray(0, 5).toString()).toBe("%PDF-"); else if (href.endsWith(".md")) expect(body.toString()).toContain("**Demo Checks**\n\n# Checking an example article\n"); else expect(body.subarray(0, 4).toString("hex")).toBe("504b0304"); } }); test("report.html opens with no network: one file, no script, every image inlined", async ({ page, baseURL }) => { const url = `${baseURL}${REPORT}report.html`; const asked: string[] = []; // Only the file itself may load; anything else it asked for would be refused. await page.route("**/*", (route) => { const u = route.request().url(); if (u === url) return route.continue(); asked.push(u); return route.abort(); }); await page.goto(url); await expect(page.locator("h1")).toContainText("Checking an example article"); await expect(page.locator("article[data-claim]")).toHaveCount(5); await expect(page.locator("script")).toHaveCount(0); const imgs = page.locator("img"); expect(await imgs.count()).toBeGreaterThan(0); for (const img of await imgs.all()) { expect(await img.getAttribute("src")).toMatch(/^data:image\//); await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0); } await expect(page.locator("[data-reference-list] > li")).toHaveCount(6); // The page's tiers, its flag pill and the added mark, as the site has them. await expect(page.locator("[data-report-tier]")).toHaveCount(3); await expect(page.locator("[data-claim-flag]").first()).toContainText("No source given"); await expect(page.locator("[data-citation-added] svg").first()).toBeVisible(); await expect(page.locator("[data-export-footer]")).toContainText("report sha256"); expect(asked).toEqual([]); }); test("the report names its revision beside its dates, and links its history", async ({ page }) => { await page.goto(REPORT); const revision = page.locator("[data-report-dates] a[data-report-revision]"); await expect(revision).toHaveText("revision 2"); await revision.click(); await expect(page).toHaveURL(HISTORY); await expect(page.locator("h1")).toHaveText("Revision history"); }); test("the history lists both revisions, newest first: hashes, the change summary, a claim-level word diff", async ({ page, request, }) => { const history = (await (await request.get(`${HISTORY}history.json`)).json()) as ReportHistoryView; expect(history.revisions.map((r) => r.revision)).toEqual([1, 2]); await page.goto(HISTORY); const revisions = page.locator("li[data-revision]"); await expect(revisions).toHaveCount(2); await expect(revisions.nth(0)).toHaveAttribute("data-revision", "2"); for (const r of history.revisions) { const li = page.locator(`li[data-revision="${r.revision}"]`); await expect(li.locator("h2")).toHaveText(`Revision ${r.revision}`); await expect(li.locator("[data-commit]")).toHaveText(r.commit); await expect(li.locator("[data-report-sha256]")).toHaveText(r.reportSha256); } // The summary is the commit's, from the two report.json files. const v1 = readFixtureReport("report.v1.json"); const v2 = readFixtureReport(); await expect(page.locator('li[data-revision="1"] [data-revision-summary] li')).toHaveText(reportChangeSummary(null, v1)); await expect(page.locator('li[data-revision="2"] [data-revision-summary] li')).toHaveText(reportChangeSummary(v1, v2)); // The diff: a verdict changed, words of a claim replaced, a claim added. const r2 = page.locator('li[data-revision="2"]'); const verdict = r2.locator('[data-claim-change="claim-1"] [data-field="verdict"]'); await expect(verdict.locator("del")).toHaveText("PARTLY"); await expect(verdict.locator("ins")).toHaveText("CONTRADICTED"); const text = r2.locator('[data-claim-change="claim-2"] [data-field="text"]'); await expect(text.locator("del")).toHaveText("month."); await expect(text.locator("ins")).toHaveText("year."); await expect(r2.locator('[data-claim-change="claim-5"]')).toHaveAttribute("data-status", "added"); await expect(page.locator('li[data-revision="1"] [data-claim-change]')).toHaveCount(0); await expect(page.locator("[data-history-clone]")).toHaveText( "git clone https://reports.example.org/reports/demo-factcheck/history/repo", ); // The footer of every export names the revision and its report sha256. const md = await (await request.get(`${REPORT}report.md`)).text(); expect(md).toContain(`Revision 2 · 2026-10-04 · report sha256 ${history.revisions[1].reportSha256.slice(0, 12)}`); }); test("the history on a phone: no page-wide horizontal scroll", async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); await page.goto(HISTORY); await expect(page.locator("li[data-revision]")).toHaveCount(2); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(0); }); test("the history clones over HTTP: its report.json is the one history.json names", async ({ baseURL, request }) => { const history = (await (await request.get(`${HISTORY}history.json`)).json()) as ReportHistoryView; const dest = fs.mkdtempSync(path.join(os.tmpdir(), "report-history-clone-")); try { execFileSync("git", ["clone", "--quiet", `${baseURL}${HISTORY}repo`, path.join(dest, "repo")], { env: historyGitEnv(), stdio: "pipe", }); const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex"); expect(sha(fs.readFileSync(path.join(dest, "repo", "report.json")))).toBe(history.revisions[1].reportSha256); const log = execFileSync("git", ["-C", path.join(dest, "repo"), "log", "--format=%H %s"], { env: historyGitEnv(), encoding: "utf8", }); expect(log.trim().split("\n")).toEqual([ `${history.revisions[1].commit} Revision 2`, `${history.revisions[0].commit} Revision 1`, ]); } finally { fs.rmSync(dest, { recursive: true, force: true }); } }); test("a video citation opens its moment: the clip, the cue lines with the span marked, where it is cited", async ({ page, request, }) => { await page.goto(REPORT); await inlineCite(page, "c01").getByRole("link", { name: "on stream" }).click(); await expect(page).toHaveURL(VIDEO_MOMENT); await expect(page.locator("[data-moment]")).toHaveAttribute("data-moment-kind", "video"); const video = page.locator("video[data-evidence-clip]"); await expect(video).toBeVisible(); const src = await video.getAttribute("src"); expect(src).toBe("/media/clips/demo-channel/abc123/3126.00-3151.00.mp4"); const clip = await request.get(src!); expect(clip.status()).toBe(200); expect(clip.headers()["content-type"]).toMatch(/^video\/mp4/); expect((await clip.body()).length).toBeGreaterThan(1000); // The poster is the report's still. const poster = await video.getAttribute("poster"); expect((await request.get(poster!)).status()).toBe(200); const cues = page.locator("[data-cue-lines] li"); await expect(cues).toHaveCount(6); await expect(page.locator("[data-cue-lines] li[data-in-span]")).toHaveText([ /The bridge opened in the spring of twenty nineteen,/, /I was there for it\./, ]); const citedIn = page.locator("[data-cited-in] a"); await expect(citedIn.and(page.locator(`[href="${REPORT}#claim-1"]`))).toHaveCount(1); await expect(citedIn.and(page.locator(`[href="${REPORT}#claim-5"]`))).toHaveCount(1); await citedIn.and(page.locator(`[href="${REPORT}#claim-1"]`)).click(); await expect(page).toHaveURL(`${REPORT}#claim-1`); await expect(page.locator("#claim-1")).toBeInViewport(); }); test("an audio moment plays its clip as sound", async ({ page, request }) => { await page.goto(AUDIO_MOMENT); await expect(page.locator("[data-moment]")).toHaveAttribute("data-moment-kind", "audio"); await expect(page.locator("video")).toHaveCount(0); const src = await page.locator("audio[data-evidence-clip]").getAttribute("src"); expect(src).toBe("/media/clips/demo-podcast/ep-042/610.50-628.00.m4a"); const clip = await request.get(src!); expect(clip.status()).toBe(200); expect(clip.headers()["content-type"]).toMatch(/^audio\//); await expect(page.locator("[data-cue-lines] li[data-in-span]")).toHaveText([/Like I said before, spring twenty nineteen\./]); await expect(page.locator(`[data-cited-in] a[href="${REPORT}#claim-2"]`)).toHaveCount(1); }); test("a post moment shows the captured shot and the post", async ({ page }) => { await page.goto(POST_MOMENT); await expect(page.locator("[data-moment]")).toHaveAttribute("data-moment-kind", "post"); await expectImageLoaded(page.locator('img[src="/media/posts/demo-social/1234567890/shot.png"]')); await expect(page.locator("[data-moment]")).toContainText("Posted to settle it."); await expect(page.locator("video, audio")).toHaveCount(0); await expect(page.locator(`[data-cited-in] a[href="${REPORT}#claim-3"]`)).toHaveCount(1); }); for (const route of ["/ask/", "/downloads/", "/duplicates/"]) { test(`${route} says this site publishes its reports only`, async ({ page }) => { await page.goto(route); await expect(page.getByText("This site publishes its reports only.")).toBeVisible(); await expect(page.getByRole("link", { name: "Read the report" })).toHaveAttribute("href", "/"); await expect(page.locator("header nav a")).toHaveCount(0); }); } test("reading the whole site never asks for the corpus", async ({ page, traffic }) => { for (const route of ["/", REPORT, HISTORY, VIDEO_MOMENT, AUDIO_MOMENT, POST_MOMENT, "/ask/", "/downloads/"]) { await page.goto(route); await page.waitForLoadState("networkidle"); } // The recorder saw the pages' own requests (the corpus check runs after // every test, this one included). expect(traffic.requested).toContain(REPORT); expect(traffic.requested.some((p) => p.startsWith("/_next/"))).toBe(true); });