Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 24294e60f987bc3c979a77856c1e8b7789545f0a
parent cf2e17f66890986156e6d231964ff3e418915803
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  2 Jul 2026 02:23:55 -0400

Phase 8 (core): hub federation e2e — cross-origin browse + search verified

Add a hub-mode Playwright project (playwright.hub.config.ts, e2e:hub script,
e2e-hub/federation.spec.ts) that runs the export app with INSTANCE_MODE=hub and
route-mocks a second origin's federation contract WITH CORS headers, so the
browser's real same-origin behavior is exercised without a second static server.

Verified end-to-end (4 passing):
- add-by-URL reads a cross-origin /site.json + /summaries/manifest.json and puts
  the archive on the shelf;
- a metadata search returns hits merged from the added origin, and the result
  slug is origin-qualified (makeId) — proving cross-origin identity rewriting;
- a malformed URL and a future-contract descriptor are both rejected with the
  custom copy.

Real cross-origin CORS *rejection* can't be exercised through route mocks
(fulfilled responses bypass the browser's CORS check) — it's proven by the
production _headers / serve.json and will be covered by a real two-origin
offline test alongside the hub SW.

Also: HubHome form gets noValidate so validateSite is the single gate (native
url validation was pre-empting the custom error), and the submit handler is
inlined (drops a deprecated FormEvent annotation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
M.gitignore | 2++
Mexport/app/components/hub/HubHome.tsx | 12+++++++++---
Aexport/e2e-hub/federation.spec.ts | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/package.json | 1+
Aexport/playwright.hub.config.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 251 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -79,8 +79,10 @@ yarn-error.log* # export e2e fixtures and ephemeral state /export/test-public/ /export/test-settings.json +/export/test-settings.hub.json /export/playwright-report/ /export/test-results/ +/export/test-results-hub/ /export/blob-report/ # site config diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx @@ -40,8 +40,7 @@ export default function HubHome() { [sites], ); - async function onAdd(e: React.FormEvent<HTMLFormElement>) { - e.preventDefault(); + async function onAdd() { if (add.status === "validating") return; setAdd({ status: "validating" }); const result = await validateSite(url); @@ -61,7 +60,14 @@ export default function HubHome() { <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground"> Your archives </h1> - <form onSubmit={onAdd} className="flex flex-wrap items-center gap-2"> + <form + onSubmit={(e) => { + e.preventDefault(); + void onAdd(); + }} + noValidate + className="flex flex-wrap items-center gap-2" + > <input type="url" inputMode="url" diff --git a/export/e2e-hub/federation.spec.ts b/export/e2e-hub/federation.spec.ts @@ -0,0 +1,188 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +// 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", { name: "Your archives" }), + ).toBeVisible(); + }); + + test("adds an archive by URL and shows it on the shelf", async ({ page }) => { + await mockOriginB(page); + await addArchive(page, ORIGIN_B); + // Chip carries the descriptor's siteTitle → the site was read cross-origin. + await expect(page.getByText("Origin B")).toBeVisible(); + }); + + test("searches across the added origin and returns origin-qualified hits", async ({ + page, + }) => { + await mockOriginB(page); + await addArchive(page, ORIGIN_B); + await expect(page.getByText("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 <p role="alert">; 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", + ); + }); +}); diff --git a/export/package.json b/export/package.json @@ -19,6 +19,7 @@ "start": "serve out", "lint": "eslint", "e2e": "playwright test", + "e2e:hub": "playwright test --config playwright.hub.config.ts", "e2e:ui": "playwright test --ui", "deploy": "pnpm dlx wrangler pages deploy out" }, diff --git a/export/playwright.hub.config.ts b/export/playwright.hub.config.ts @@ -0,0 +1,51 @@ +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; +import { buildFixtureSettings } from "./e2e/fixtures/data"; + +// Hub-mode e2e: the export app built/run with INSTANCE_MODE=hub renders the +// federated shelf and reads other origins at runtime. Cross-origin JSON is +// route-mocked WITH CORS headers per spec, so the browser's real same-origin / +// CORS enforcement is exercised (a fulfilled cross-origin response the hub can +// read only because it carries Access-Control-Allow-Origin) without standing up +// a second real static server. Real _headers/serve.json CORS + the offline SW +// are covered separately once the hub SW lands. +const PORT = Number(process.env.HUB_PORT ?? 3041); +const baseURL = `http://localhost:${PORT}`; + +const TEST_SETTINGS_FILE = path.resolve(process.cwd(), "test-settings.hub.json"); +const TEST_SITES_DIR = path.resolve(process.cwd(), "e2e", "fixtures", "sites"); + +buildFixtureSettings(TEST_SETTINGS_FILE); + +export default defineConfig({ + testDir: "./e2e-hub", + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + outputDir: "test-results-hub/", + fullyParallel: false, + workers: 1, + webServer: { + command: `pnpm dev --port ${PORT}`, + url: baseURL, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + env: { + INSTANCE_MODE: "hub", + SETTINGS_FILE: TEST_SETTINGS_FILE, + SITES_DIR: TEST_SITES_DIR, + }, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 1200 } }, + }, + ], +});