import { expect, test, type Page, type Route } from "@playwright/test"; import { PROJECT_URL } from "../../common/lib/project"; // Federation core: the hub reads a DIFFERENT origin's static JSON contract over // (simulated) CORS and merges it into one search. Origin B is never really // served — every request to it is route-mocked. Because these are cross-origin // fulfillments, the browser still applies its CORS check, so a mock the hub can // read MUST carry Access-Control-Allow-Origin (that's the whole federation // requirement). Omitting it reproduces a real blocked read. const ORIGIN_B = "http://localhost:4599"; const CHANNEL_B = "Channel B"; const CHANNEL_B_SLUG = "chan-b"; const CORS = { "access-control-allow-origin": "*" }; async function fulfillJson(route: Route, body: unknown, cors = true) { await route.fulfill({ status: 200, contentType: "application/json", headers: cors ? CORS : {}, body: JSON.stringify(body), }); } function descriptorB() { return { contract: 1, siteId: "originb", siteTitle: "Origin B", siteDescription: "A federated archive.", headerTitle: "Origin B", homeTagline: "", accent: "#c026d3", siteUrl: ORIGIN_B, pwa: false, socialLinks: [], groups: [{ id: "gb", name: "Origin B", selectedByDefault: true }], defaultGroupId: "gb", channels: [ { slug: CHANNEL_B_SLUG, name: CHANNEL_B, count: 1, groupId: "gb" }, ], generatedAt: "2026-01-01T00:00:00.000Z", summariesVersion: 3, }; } function manifestB() { return { version: 3, totalCount: 1, pageSize: 1000, pageCount: 1, generatedAt: "2026-01-01T00:00:00.000Z", channels: [ { name: CHANNEL_B, count: 1, slug: CHANNEL_B_SLUG, groupId: "gb" }, ], groups: [{ id: "gb", name: "Origin B", selectedByDefault: true }], defaultGroupId: "gb", siteId: "originb", }; } function summariesB() { return [ { slug: `${CHANNEL_B_SLUG}/vidb1`, id: "vidb1", channelSlug: CHANNEL_B_SLUG, title: "Bravo Video", uploadDate: "20260101", date: "2026-01-01", duration: "5:00", channel: CHANNEL_B, isLivestream: false, ageRestricted: false, isDeleted: false, isUnlisted: false, platform: "youtube" as const, webpageUrl: `${ORIGIN_B}/vidb1`, }, ]; } // Route-mock Origin B's federation contract. `cors` toggles the ACAO header on // the descriptor to simulate a site that isn't sharing its data. async function mockOriginB(page: Page, { cors = true }: { cors?: boolean } = {}) { await page.route(`${ORIGIN_B}/site.json`, (r) => fulfillJson(r, descriptorB(), cors), ); await page.route(`${ORIGIN_B}/summaries/manifest.json`, (r) => fulfillJson(r, manifestB()), ); await page.route(`${ORIGIN_B}/summaries/page-*.json`, (r) => fulfillJson(r, summariesB()), ); // No subs on Origin B. await page.route(`${ORIGIN_B}/subs/manifest.json`, (r) => fulfillJson(r, { version: 4, channels: [], totalCount: 0, liveChatTotalCount: 0, generatedAt: "2026-01-01T00:00:00.000Z", }), ); } // The hub has no built-in pool in these tests — keep it empty so only the // added origin is exercised (and avoid a 404 on the missing file). async function stubBuiltins(page: Page) { await page.route("**/hub-sites.json", (r) => fulfillJson(r, [])); } async function addArchive(page: Page, url: string) { await page.getByLabel("Archive URL").fill(url); await page.getByRole("button", { name: "Add", exact: true }).click(); } test.describe("hub federation — cross-origin browse + search", () => { test.beforeEach(async ({ page }) => { await stubBuiltins(page); await page.goto("/"); await expect( page.getByRole("heading", { level: 1, name: /searchable at once\./ }), ).toBeVisible(); }); test("hub mode carries the same project back-link as a site", async ({ page, }) => { // The back-link is deliberately NOT gated on instanceMode(): a hub is still // something Archilyzer built, and a credit that appears on sites but not on // hubs would make the two look like different software. const builtWith = page.locator("footer").getByRole("link", { name: "Archilyzer", }); await expect(builtWith).toBeVisible(); await expect(builtWith).toHaveAttribute("href", PROJECT_URL); }); test("adds an archive by URL and shows it under Archives You Added", async ({ page }) => { await mockOriginB(page); await addArchive(page, ORIGIN_B); // Spine carries the descriptor's siteTitle → the site was read cross-origin. await expect( page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }), ).toBeVisible(); // An added archive gets its own section, and its own Remove control. await expect( page.getByRole("heading", { level: 2, name: "Archives You Added", exact: true }), ).toBeVisible(); await expect( page.getByRole("button", { name: "Remove Origin B" }), ).toBeAttached(); }); test("searches across the added origin and returns origin-qualified hits", async ({ page, }) => { await mockOriginB(page); await addArchive(page, ORIGIN_B); await expect( page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }), ).toBeVisible(); // Wait for the merged search UI to hydrate, then metadata-search "bravo". await page.getByTestId("query-builder").waitFor(); await page .locator('[data-testid^="leaf-scope-"]') .first() .selectOption({ label: "Title / channel" }); const input = page.locator('[data-testid^="leaf-query-"]').first(); await input.click(); await input.fill("bravo"); await input.press("Enter"); await page.waitForURL(/[?&]qt=/); // The result exists (proves Origin B's summaries were fetched + merged) and // its slug is origin-qualified (proves cross-origin identity rewriting). const result = page.locator("[data-result-slug]").first(); await expect(result).toBeVisible(); await expect(result).toHaveAttribute( "data-result-slug", new RegExp("localhost:4599"), ); }); // HubHome's error is a

; scope to it so it isn't confused // with Next's empty route-announcer div (also role=alert). const errorAlert = (page: Page) => page.locator('p[role="alert"]'); test("rejects a bad URL", async ({ page }) => { await addArchive(page, "not a url"); await expect(errorAlert(page)).toContainText("full site URL"); }); test("rejects a descriptor whose contract the hub doesn't understand", async ({ page, }) => { // A site speaking a future contract version is refused (validateSite checks // contract === SITE_DESCRIPTOR_VERSION). (Real cross-origin CORS *rejection* // can't be exercised through route mocks — fulfilled responses bypass the // browser's CORS check — so it's proven by the production _headers, not here.) await page.route(`${ORIGIN_B}/site.json`, (r) => fulfillJson(r, { ...descriptorB(), contract: 99 }), ); await addArchive(page, ORIGIN_B); await expect(errorAlert(page)).toContainText( "version this hub doesn't understand", ); }); });