import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { ChannelConfig } from "../lib/channelConfig"; import type { Paths } from "../lib/paths"; import type { Site } from "../lib/site"; import { hubInputSig, siteInputSig } from "./inputSig"; // Run with: // pnpm --filter yt-dlp-transcript-common test // // A site's inputSig moves exactly with what compose reads (release 18): its // staged index tree, its PUBLISHED members' shared trees (manifest.json aside, // a manifest-only tree signed as one), site.json, the corpus-wide files and the // two settings — and not with another site's channel, a churned manifest, or // a rewritten-but-identical chart-templates.json. function setup() { const root = mkdtempSync(path.join(os.tmpdir(), "input-sig-")); const idx = path.join(root, ".export-index"); const paths = { transcriptsDir: path.join(root, "transcripts"), sitesDir: path.join(root, "transcripts", "sites"), exportSitesIndexDir: path.join(idx, "sites"), exportSharedTranscriptsDir: path.join(idx, "shared", "transcripts"), exportSharedSubsDir: path.join(idx, "shared", "subs"), exportSharedPostsDir: path.join(idx, "shared", "posts"), exportSharedDigestsDir: path.join(idx, "shared", "digests"), globalAliasesFile: path.join(root, "transcripts", "search-aliases.json"), globalTagsFile: path.join(root, "transcripts", "tags.json"), homepageConfigFile: path.join(root, "transcripts", "sites", "_homepage", "homepage.json"), } as Paths; const site = { siteId: "jer", siteTitle: "Jer", channels: [{ slug: "a" }, { slug: "x" }], } as unknown as Site; const write = (file: string, body: string) => { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, body); }; write(path.join(paths.sitesDir, "jer", "site.json"), JSON.stringify({ siteId: "jer" })); write(path.join(paths.exportSitesIndexDir, "jer", "summaries", "page-0000.json"), "[]"); write(path.join(paths.exportSitesIndexDir, "jer", "chart-templates.json"), '{"v":1}'); write(path.join(paths.exportSharedTranscriptsDir, "a", "page-0000.json"), "[1]"); write(path.join(paths.exportSharedTranscriptsDir, "a", "manifest.json"), '{"generatedAt":"1"}'); write(path.join(paths.exportSharedPostsDir, "x", "page-0000.json"), "[2]"); write(path.join(paths.exportSharedTranscriptsDir, "other", "page-0000.json"), "[3]"); const configs: Record> = { a: {}, x: { sourceKind: "social", platform: "twitter" } as Partial, }; let settings: Record = { socialLinks: [], buildArchives: true }; let sites: Site[] = [ site, { siteId: "ani", siteTitle: "Ani", siteUrl: "https://ani.pages.dev", channels: [] } as unknown as Site, ]; const sig = () => siteInputSig({ paths, site, settings: settings as never, sites, configOf: (slug) => configs[slug] as ChannelConfig, }); const bump = (file: string) => { const t = new Date(Date.now() + 5_000); utimesSync(file, t, t); }; return { root, paths, write, sig, bump, setXPrivate: () => { settings = { ...settings, social: { x: { visibility: "private" } } }; }, setSettings: (over: Record) => { settings = { ...settings, ...over }; }, setSites: (next: Site[]) => { sites = next; }, site, }; } test("inputSig is stable, and ignores what compose ignores", async () => { const s = setup(); try { const base = await s.sig(); assert.match(base, /^[0-9a-f]{40}$/); assert.equal(await s.sig(), base, "nothing changed"); // A churned shared manifest (generatedAt) does not count… s.write(path.join(s.paths.exportSharedTranscriptsDir, "a", "manifest.json"), '{"generatedAt":"2"}'); assert.equal(await s.sig(), base); // …nor another site's channel… s.write(path.join(s.paths.exportSharedTranscriptsDir, "other", "page-0001.json"), "[4]"); assert.equal(await s.sig(), base); // …nor chart-templates.json rewritten with the same bytes. const tpl = path.join(s.paths.exportSitesIndexDir, "jer", "chart-templates.json"); s.bump(tpl); assert.equal(await s.sig(), base); } finally { rmSync(s.root, { recursive: true, force: true }); } }); test("inputSig moves with each input compose reads", async () => { const s = setup(); try { let last = await s.sig(); const moved = async (what: string) => { const now = await s.sig(); assert.notEqual(now, last, what); last = now; }; s.write(path.join(s.paths.exportSharedTranscriptsDir, "a", "page-0001.json"), "[5]"); await moved("a member's shared transcripts"); s.write(path.join(s.paths.exportSharedPostsDir, "x", "page-0001.json"), "[6]"); await moved("a member's shared posts"); s.write(path.join(s.paths.exportSitesIndexDir, "jer", "summaries", "page-0001.json"), "[]"); await moved("the site's staged summaries"); s.write(path.join(s.paths.exportSitesIndexDir, "jer", "chart-templates.json"), '{"v":2}'); await moved("the chart templates' bytes"); s.write(path.join(s.paths.sitesDir, "jer", "site.json"), JSON.stringify({ siteId: "jer", t: 1 })); await moved("site.json"); s.write(path.join(s.paths.sitesDir, "jer", "tags.json"), "{}"); await moved("the site's config dir"); s.write(s.paths.globalAliasesFile, "{}"); await moved("the global search aliases"); s.write(s.paths.globalTagsFile, "{}"); await moved("the curated tags"); s.write(path.join(s.paths.transcriptsDir, "duplicates.json"), "{}"); await moved("the duplicates report"); // X posts private: the X member is withheld from a public site. s.setXPrivate(); await moved("social.x.visibility"); s.write(path.join(s.paths.exportSharedPostsDir, "x", "page-0002.json"), "[7]"); assert.equal(await s.sig(), last, "a withheld member's tree is not this site's input"); } finally { rmSync(s.root, { recursive: true, force: true }); } }); test("inputSig moves with what the export BUILD renders: social links, the hub url, buildArchives, the sibling sites", async () => { const s = setup(); try { let last = await s.sig(); const moved = async (what: string) => { const now = await s.sig(); assert.notEqual(now, last, what); last = now; }; s.setSettings({ socialLinks: [{ label: "X", url: "https://x.com/a" }] }); await moved("settings.socialLinks (the header/footer links)"); s.setSettings({ homepageUrl: "https://hub.pages.dev" }); await moved("settings.homepageUrl (the site's hubUrl)"); s.setSettings({ buildArchives: false }); await moved("settings.buildArchives"); const ani = { siteId: "ani", siteTitle: "Ani", siteUrl: "https://ani.pages.dev", channels: [] }; s.setSites([s.site, { ...ani, siteTitle: "Ani 2" } as unknown as Site]); await moved("a sibling's title"); s.setSites([s.site, { ...ani, siteTitle: "Ani 2", siteUrl: "https://ani2.pages.dev" } as unknown as Site]); await moved("a sibling's url"); s.setSites([s.site, { ...ani, siteTitle: "Ani 2", siteUrl: "https://ani2.pages.dev" } as unknown as Site, { siteId: "bon", siteTitle: "Bon", siteUrl: "https://bon.pages.dev", channels: [], } as unknown as Site]); await moved("a sibling launched"); // A sibling the footer does not list (no url) changes nothing. s.setSites([s.site, { ...ani, siteTitle: "Ani 2", siteUrl: "https://ani2.pages.dev" } as unknown as Site, { siteId: "bon", siteTitle: "Bon", siteUrl: "https://bon.pages.dev", channels: [], } as unknown as Site, { siteId: "dark", siteTitle: "Dark", channels: [] } as unknown as Site]); assert.equal(await s.sig(), last, "an unlinkable sibling is not in the footer"); } finally { rmSync(s.root, { recursive: true, force: true }); } }); test("a manifest-only member tree is signed as one, not as absent", async () => { const s = setup(); try { const before = await s.sig(); s.write(path.join(s.paths.exportSharedSubsDir, "a", "manifest.json"), "{}"); assert.notEqual(await s.sig(), before); } finally { rmSync(s.root, { recursive: true, force: true }); } }); test("hubSig: the stamp, homepage.json, and each listed site's id + url + title", async () => { const s = setup(); try { const sites = [ { siteId: "jer", siteTitle: "Jer", siteUrl: "https://jer.pages.dev", channels: [] }, { siteId: "mine", siteTitle: "Mine", siteUrl: "https://m.pages.dev", audience: "private", channels: [] }, { siteId: "nourl", siteTitle: "No", channels: [] }, ] as unknown as Site[]; const a = await hubInputSig(s.paths, "s1", sites); assert.equal(await hubInputSig(s.paths, "s1", sites), a); assert.notEqual(await hubInputSig(s.paths, "s2", sites), a, "a new index stamp"); const retitled = sites.map((x) => (x.siteId === "jer" ? { ...x, siteTitle: "Jer 2" } : x)); assert.notEqual(await hubInputSig(s.paths, "s1", retitled), a); // A private site or one with no url is not listed: changing it moves nothing. const privRetitled = sites.map((x) => (x.siteId === "mine" ? { ...x, siteTitle: "M2" } : x)); assert.equal(await hubInputSig(s.paths, "s1", privRetitled), a); s.write(s.paths.homepageConfigFile, '{"title":"hub"}'); assert.notEqual(await hubInputSig(s.paths, "s1", sites), a); } finally { rmSync(s.root, { recursive: true, force: true }); } });