import fs from "node:fs"; import { buildHomepageSummary } from "../../common/lib/homepageSummary"; import type { Site } from "../../common/lib/site"; import type { VideoStat } from "../../common/lib/stats"; // THE HOMEPAGE E2E READS A SYNTHETIC SUMMARY, never the operator's. // // The pages' numbers come from homepage-summary.json, which compose writes into // public/ from a real corpus — gitignored, so a fresh clone or worktree has // none, and the data specs used to skip there (release 10: 8 of 29). So the // e2e dev server reads THIS instead (E2E_HOMEPAGE_SUMMARY_FILE, app/lib/summary.ts, // honoured only outside a production build): the real builder // (buildHomepageSummary) over made-up recordings, deterministic, written by // playwright.config.ts to e2e/.e2e-summary.json (gitignored, not linked) before // the server starts. Nothing is read from public/ or from another checkout, and // every spec that reads numbers reads this file. // // What the data is shaped to exercise: // • six public sites (the chart's six validated slots), in the order the // builder sorts them (transcripts, most first); // • their accents: a named accent (Brass), a PALE custom hex (fitted per // base on the cards, 1.43:1 on the light ground as published), three with // none, and Vermilion (the sixth slot's family); // • the first site dwarfs the rest, with one week of 12,000 transcripts in // May (the "megaspike", outside the six-week window), so /stats' All-time // symlog axis reaches 10K while the Recent weeks stay linear and small; // • every site transcribes every day of the last 200, so every line is full // and every series has a non-zero start for Indexed; // • uploads spread over 2019–2026, for the growth chart's months; // • the last two sites each under 5 % of the growth chart's total (4.35 and // 3.26 %), so the chart folds them into its Other band (release 14 slice // CF; fixture-five transcribes 4 a day, not 5, for it); // • a seventh site, UNLISTED (site.json `listed: false`, release 14 slice // HS): its own two channels transcribe every day like the rest, and it // also exposes the first site's first channel. The summary names it nowhere // and counts its own channels in no total, so `buildFixtureSummary()` is // exactly `buildFixtureSummary(FIXTURE_SITES, [])` — every number the specs // read is the six listed sites'. Every site in the second list is unlisted: // `buildFixtureInputs` sets `listed: false` on it, whatever it carries. export const FIXTURE_SUMMARY_NAME = ".e2e-summary.json"; // "Now", for the builder: the build month (September 2026) is partial and is // not charted; the week and day buckets end here. export const FIXTURE_NOW = new Date("2026-09-15T12:00:00Z"); // A pale custom hex: published as is it would be a ghost on light. export const FIXTURE_PALE_HEX = "#f4c2d7"; // The six sites, in summary order. `accent` is the site.json setting (an // accent id or a custom hex), as compose reads it; `wordmarkLead` the // site.json one (a named accent's site, the custom hex's and a site with no // accent have one — the last ends MID-WORD, "Fix" + "ture Three", as // "Jer" + "alyzer" does; the rest show their title plain); `daily` how many // recordings each channel transcribes a day. Whether a site is listed is the // list it is passed in (buildFixtureInputs), not a field. export type FixtureSite = { siteId: string; siteTitle: string; accent?: string; wordmarkLead?: string; channels: number; daily: number; }; export const FIXTURE_SITES: readonly FixtureSite[] = [ { siteId: "fixture-one", siteTitle: "Fixture One", accent: "brass", wordmarkLead: "Fixture", channels: 4, daily: 15 }, { siteId: "fixture-two", siteTitle: "Fixture Two", accent: FIXTURE_PALE_HEX, wordmarkLead: "Fixture", channels: 3, daily: 9 }, { siteId: "fixture-three", siteTitle: "Fixture Three", wordmarkLead: "Fix", channels: 3, daily: 7 }, { siteId: "fixture-four", siteTitle: "Fixture Four", channels: 2, daily: 8 }, { siteId: "fixture-five", siteTitle: "Fixture Five", channels: 2, daily: 4 }, { siteId: "fixture-six", siteTitle: "Fixture Six", accent: "vermilion", channels: 2, daily: 3 }, ]; // The unlisted site (an invented id, as every fixture's). Not in FIXTURE_SITES, // which is the six the pages show; buildFixtureInputs' second list, which // unlists it. export const FIXTURE_UNLISTED_SITE: FixtureSite = { siteId: "fixture-unlisted", siteTitle: "Fixture Unlisted", channels: 2, daily: 6, }; const DAY = 86_400_000; const SPIKE = { site: 0, start: Date.UTC(2026, 4, 11), days: 7, count: 12_000 }; const HISTORY_DAYS = 200; const ymd = (t: number) => new Date(t).toISOString().slice(0, 10).replace(/-/g, ""); // A recording's upload date: spread over the 92 months before the build month, // deterministically (a multiplicative walk over the index, so every site has // uploads in most months). function uploadDate(n: number): string { const back = 1 + ((n * 37) % 92); // months before September 2026 const d = new Date(Date.UTC(2026, 8 - back, 1 + ((n * 11) % 28))); return ymd(d.getTime()); } // The builder's inputs: the recordings, the channel → sites map and the sites. // `fixtureSites` are listed; every site in `unlistedSites` is written with // `listed: false`, and each also exposes the first listed site's first channel. export function buildFixtureInputs( fixtureSites: readonly FixtureSite[] = FIXTURE_SITES, unlistedSites: readonly FixtureSite[] = [FIXTURE_UNLISTED_SITE], ): { stats: VideoStat[]; channelSites: Record; sites: Site[] } { const stats: VideoStat[] = []; const channelSites: Record = {}; const sites: Site[] = []; let n = 0; const record = (siteIdx: number, slug: string, name: string, day: number) => { n += 1; stats.push({ slug: `${slug}/v${n}`, id: `v${n}`, channelSlug: slug, channel: name, title: `Fixture recording ${n}`, platform: "youtube", uploadDate: uploadDate(n), downloadedDate: ymd(day), transcribedDate: ymd(day), timestamp: null, duration: 1200 + ((n * 97) % 5400), viewCount: null, likeCount: null, commentCount: null, channelFollowerCount: null, categories: [], tags: [], language: "en", isLivestream: false, mediaType: "video", // One in forty re-checked and found gone at the source. status: n % 40 === siteIdx ? "deleted" : "available", hasTranscript: true, cueCount: null, coverage: null, }); }; const today = Date.UTC(2026, 8, 15); // A site's own channels, `-ch`, each transcribing every day of the // last HISTORY_DAYS; `shared` are other sites' channels it also exposes. const addSite = ( s: FixtureSite, i: number, { listed = true, shared = [] }: { listed?: boolean; shared?: string[] } = {}, ) => { const slugs = Array.from({ length: s.channels }, (_, c) => `${s.siteId}-ch${c + 1}`); for (const slug of [...slugs, ...shared]) (channelSites[slug] ??= []).push(s.siteId); sites.push({ siteId: s.siteId, siteTitle: s.siteTitle, siteDescription: `${s.siteTitle}, an e2e fixture archive.`, siteUrl: `https://${s.siteId}.example`, channels: [...slugs, ...shared].map((slug) => ({ slug })), ...(s.accent ? { accent: s.accent } : {}), ...(s.wordmarkLead ? { wordmarkLead: s.wordmarkLead } : {}), ...(listed ? {} : { listed: false }), } as unknown as Site); slugs.forEach((slug, c) => { const name = `${s.siteTitle} Channel ${c + 1}`; for (let d = 0; d < HISTORY_DAYS; d++) { const day = today - d * DAY; // A little day-to-day rhythm, never zero. const count = Math.max(1, s.daily - c * 2 + ((d + c) % 3)); for (let k = 0; k < count; k++) record(i, slug, name, day); } }); }; fixtureSites.forEach((s, i) => addSite(s, i)); // The megaspike: one week of bulk transcription on the first site's first // channel. const spikeSite = fixtureSites[SPIKE.site]; for (let k = 0; k < SPIKE.count; k++) { record(SPIKE.site, `${spikeSite.siteId}-ch1`, `${spikeSite.siteTitle} Channel 1`, SPIKE.start + (k % SPIKE.days) * DAY); } // The unlisted sites LAST, so every listed site's recordings (their numbers, // dates and states) are the same with them or without. unlistedSites.forEach((s, j) => addSite(s, fixtureSites.length + j, { listed: false, shared: [`${spikeSite.siteId}-ch1`], }), ); return { stats, channelSites, sites }; } // The summary the e2e dev server reads: the real builder over those inputs. export function buildFixtureSummary( fixtureSites: readonly FixtureSite[] = FIXTURE_SITES, unlistedSites: readonly FixtureSite[] = [FIXTURE_UNLISTED_SITE], ) { const { stats, channelSites, sites } = buildFixtureInputs(fixtureSites, unlistedSites); return buildHomepageSummary(stats, channelSites, sites, FIXTURE_NOW); } // Write the fixture summary to `dest`. The write is a rename, because // Playwright evaluates its config again in every worker it starts, while the // dev server may be reading the file. export function writeFixtureSummary(dest: string): void { const tmp = `${dest}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(buildFixtureSummary())); fs.renameSync(tmp, dest); }