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:
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 } },
+ },
+ ],
+});