import fs from "node:fs"; import path from "node:path"; import { expect, test, type Page, type Route } from "@playwright/test"; import { normalizeSocialSvg } from "../../common/lib/settingsSchema"; import { resolveAccent } from "../../common/lib/accent"; import { ACCENTS } from "../../common/lib/brand"; // The hub's "Official Instances": one card per built-in member // (hub-sites.json), carrying the homepage's own figures for it when the hub was // composed with them (hub-summary.json, written by compose-hub from the same // buildHomepageSummary call the homepage makes). The summary is OPTIONAL — an // older build or a hub composed with no index ships none — and then the card // renders with no figures, never an error. // A member origin that is never really served: its data is 404 (with CORS so // the browser reports a clean 404, not a blocked read). The cards come from // hub-sites.json + hub-summary.json alone, so nothing here needs its data. const MEMBER = "http://localhost:4598"; async function fulfillJson(route: Route, body: unknown) { await route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, body: JSON.stringify(body), }); } async function stubMember(page: Page, { accent }: { accent?: string } = {}) { await page.route("**/hub-sites.json", (r) => fulfillJson(r, [ { siteId: "member", siteTitle: "Member Archive", siteUrl: MEMBER, ...(accent ? { accent } : {}), pwa: false, contract: 1, }, ]), ); await page.route(`${MEMBER}/**`, (r) => r.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" }, body: "" }), ); } const SUMMARY = { version: 1, generatedAt: "2026-09-25T00:00:00.000Z", official: { sites: 1, channels: 12, recordings: 3456, transcripts: 2345, hoursArchived: 7890, gone: 3, }, sites: [ { siteId: "member", siteTitle: "Member Archive", siteDescription: "Every word the member said.", siteUrl: MEMBER, channels: 12, recordings: 3456, transcripts: 2345, hoursArchived: 7890, gone: 3, }, ], }; test.describe("hub official instances", () => { test("with hub-summary.json, an official card shows its four figures", async ({ page, }) => { await stubMember(page); await page.route("**/hub-summary.json", (r) => fulfillJson(r, SUMMARY)); await page.goto("/"); await expect( page.getByRole("heading", { level: 1, name: "7,890 hours of speech from every official archive, searchable at once.", }), ).toBeVisible(); await expect( page.getByRole("heading", { level: 2, name: "Official Instances", exact: true, }), ).toBeVisible(); // No subtitle under the heading (operator ruling 2026-09-25: the title // implies it). await expect(page.getByText("The archives I run")).toHaveCount(0); const card = page .getByTestId("shelf-spine") .filter({ hasText: "Member Archive" }); await expect(card).toBeVisible(); await expect( card.getByRole("link", { name: /Member Archive/ }), ).toHaveAttribute("target", "_blank"); // The summary carries a description, and the card does not show it: no // subtitle unless needed. The four figures carry the card. await expect(card).not.toContainText("Every word the member said."); for (const [value, unit] of [ ["12", "channels"], ["3,456", "recordings"], ["2,345", "transcripts"], ["7,890", "hours"], ]) { await expect(card.locator("dd", { hasText: value })).toBeVisible(); await expect(card.locator("dt", { hasText: unit })).toBeVisible(); } // A built-in member is not the visitor's to remove, and there is no // "Archives you added" section until they add one. await expect(card.getByRole("button", { name: /^Remove/ })).toHaveCount(0); await expect( page.getByRole("heading", { name: "Archives You Added" }), ).toHaveCount(0); }); // The hub lists the instances itself: its header has the group (the theme // toggle; the social row when there are links) and no link to the // Archilyzer home's list, no sites menu and no hub link. test("the hub's header has no Archilyzer link, sites menu or theme menu", async ({ page }) => { await stubMember(page); await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" }), ); await page.goto("/"); const banner = page.getByRole("banner"); await expect(banner.getByRole("button", { name: /^switch to /i })).toBeVisible(); await expect(banner.getByRole("link", { name: /official instances/i })).toHaveCount(0); await expect(banner.getByRole("button", { name: /sites/i })).toHaveCount(0); await expect(banner.getByRole("button", { name: "Choose theme" })).toHaveCount(0); await expect(banner.getByRole("link", { name: "Changelog" })).toHaveCount(0); }); // The hub's header follows the same ruling as every site's: on a narrow // screen the name and only the marked link(s); from 520 px every link. The // hub's links come from the settings file the suite serves; this test writes // three (the last marked) and puts the file back. Both pointers: under // touch the keys are 44 px, and the name still shows at 360 px. for (const touch of [false, true]) { test.describe(touch ? "under a coarse pointer" : "with a mouse", () => { test.use({ hasTouch: touch }); test("the hub's header keeps its name at 360 and 390 px and shows only the marked link", async ({ page }) => { const file = path.resolve(process.cwd(), "test-settings.hub.json"); const pristine = fs.readFileSync(file, "utf8"); try { const icon = (d: string) => normalizeSocialSvg(``)!; const links = [ { label: "Square", url: "https://square.example/", svg: icon("M4 4h16v16H4z") }, { label: "Bar", url: "https://bar.example/", svg: icon("M3 10h18v4H3z") }, { label: "Post", url: "https://post.example/", svg: icon("M10 2h4v20h-4z"), featured: true }, ]; fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(pristine), socialLinks: links }, null, 2)); await stubMember(page); await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" })); const banner = page.getByRole("banner"); const row = page.locator('header [data-social-links="header"]:visible'); for (const width of [360, 390]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); await expect(banner.getByRole("button", { name: /^switch to /i })).toBeVisible(); const text = await page.locator("header [data-header-brand] span.flex-wrap").evaluate( (box) => (box.lastElementChild as HTMLElement).offsetTop < box.clientHeight - 1, ); expect(text, `${width} px: the name`).toBe(true); await expect(row.getByRole("link")).toHaveCount(1); await expect(banner.getByRole("link", { name: "Post", exact: true })).toHaveCount(1); await expect(banner.getByRole("link", { name: "Square", exact: true })).toHaveCount(0); } await page.setViewportSize({ width: 1280, height: 800 }); await page.goto("/"); await expect(row.getByRole("link")).toHaveCount(3); await expect(page.locator('footer [data-social-links="footer"]').getByRole("link")).toHaveCount(3); } finally { fs.writeFileSync(file, pristine); } }); }); } test("the hub has no link to ko-fi.com", async ({ page }) => { await stubMember(page); await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" }), ); await page.goto("/"); await expect(page.locator("footer")).toBeVisible(); await expect(page.locator('a[href*="ko-fi.com"]')).toHaveCount(0); }); test("without hub-summary.json, the card renders with no figures", async ({ page, }) => { await stubMember(page); await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" }), ); await page.goto("/"); await expect( page.getByRole("heading", { level: 1, name: "Every official archive, searchable at once.", }), ).toBeVisible(); const card = page .getByTestId("shelf-spine") .filter({ hasText: "Member Archive" }); await expect(card).toBeVisible(); await expect(card.getByText("Data-only")).toBeVisible(); await expect(card.locator("dl")).toHaveCount(0); }); // Release 10: a NAMED accent is painted at its value on the base in force. // hub-sites.json carries only its on-dark hex; hub-summary.json names the // accent (`accentId`), and the card wears `var(--swatch-)`. test("a named accent wears its own value on each base", async ({ page }) => { await stubMember(page, { accent: ACCENTS.brass.onDark }); await page.route("**/hub-summary.json", (r) => fulfillJson(r, { ...SUMMARY, sites: [ { ...SUMMARY.sites[0], accent: ACCENTS.brass.onDark, accentId: "brass" }, ], }), ); const stripe = page .getByTestId("shelf-spine") .filter({ hasText: "Member Archive" }) .locator(":scope > span[aria-hidden='true']"); const colour = () => stripe.evaluate((el) => getComputedStyle(el).backgroundColor); const rgb = (hex: string) => `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`; // The hub opens dark. await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("data-base", "dark"); await expect.poll(colour).toBe(rgb(ACCENTS.brass.onDark)); await page.evaluate(() => localStorage.setItem("ytdlp-tb:base", "light")); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-base", "light"); await expect.poll(colour).toBe(rgb(ACCENTS.brass.onLight)); }); // Release 11 (O2b): an archive the VISITOR added wears the hex its // /site.json published, fitted to the base in force — on its card and on its // scope chip — like any custom hex. A pale one used to be painted as is: // ~1.4:1 on the light ground. test("an added archive's own hex is fitted to each base, on its card and its chip", async ({ page, }) => { const ADDED = "http://localhost:4597"; const PALE = "#f4c2d7"; await page.route("**/hub-sites.json", (r) => fulfillJson(r, [])); await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" })); await page.route(`${ADDED}/**`, (r) => r.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" }, body: "" }), ); // As the registry stores an archive added through the form. await page.addInitScript( ([origin, accent]) => { if (localStorage.getItem("ytdlp-tb:hub-sites")) return; localStorage.setItem( "ytdlp-tb:hub-sites", JSON.stringify([ { origin, siteId: "added", siteTitle: "Added Archive", accent, pwa: false, contract: 1, addedAt: 1, }, ]), ); }, [ADDED, PALE], ); const stripe = page .getByTestId("shelf-spine") .filter({ hasText: "Added Archive" }) .locator(":scope > span[aria-hidden='true']"); const dot = page .getByTestId(`hub-scope-chip-${ADDED}`) .locator("button[aria-pressed] > span.rounded-full"); const colours = async () => ({ stripe: await stripe.evaluate((el) => getComputedStyle(el).backgroundColor), dot: await dot.evaluate((el) => getComputedStyle(el).backgroundColor), }); const rgb = (hex: string) => `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`; const fitted = resolveAccent(PALE); // Fitted, not as published, where the ground needs it. expect(fitted.light).not.toBe(PALE); await page.goto("/"); await expect( page.getByRole("heading", { level: 2, name: "Archives You Added", exact: true }), ).toBeVisible(); for (const base of ["dark", "light"] as const) { if (base !== "dark") { await page.evaluate((b) => localStorage.setItem("ytdlp-tb:base", b), base); await page.reload(); } await expect(page.locator("html")).toHaveAttribute("data-base", base); const want = rgb(fitted[base]); await expect.poll(colours, { message: base }).toEqual({ stripe: want, dot: want }); } }); });