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 });
}
});
});