commit e1f1b62c70435c18afe811e6489e991b95d306c0
parent 88cd288250ae49ee3a56c61eca4220dfaa31917f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 2 Jul 2026 10:42:34 -0400
Phase 8: real two-origin e2e — cross-origin CORS + service-worker offline
Add a genuine two-origin Playwright project (playwright.2origin.config.ts,
e2e:2origin script) that proves what the route-mocked e2e-hub suite can't: the
browser's REAL CORS enforcement and the production service worker.
globalSetup stages two real static origins (config-load, before webServer since
Playwright starts servers before globalSetup):
- Origin B: a tiny hand-authored single-site archive — the JSON federation
contract (site.json + summaries + transcripts + subs) plus a serve.json that
sends Access-Control-Allow-Origin: *. No Next build (the hub only fetches its
JSON). Served on :4610.
- Origin A: a real INSTANCE_MODE=hub production build (so the SW registers),
hub-sites.json emptied so only Origin B is in play. Served on :4611.
Three passing:
1. the hub reads Origin B's /site.json over real CORS and shelves it;
2. a transcript-scope search returns a hit from Origin B (real cross-origin
summaries + transcript-page fetches), origin-qualified;
3. after a search warms the cache, going offline and fetching Origin B's
transcript shard is served from the hub SW cache — cross-origin, no network.
Deterministic on re-run (globalSetup skips the hub rebuild when staged;
TWO_ORIGIN_REBUILD=1 forces it). Staging dir .2origin/ is gitignored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 317 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -83,6 +83,8 @@ yarn-error.log*
/export/playwright-report/
/export/test-results/
/export/test-results-hub/
+/export/test-results-2origin/
+/export/.2origin/
/export/blob-report/
# site config
diff --git a/export/e2e-2origin/federation.spec.ts b/export/e2e-2origin/federation.spec.ts
@@ -0,0 +1,105 @@
+import { expect, test, type Page } from "@playwright/test";
+
+// Real two-origin federation. Origin B is a genuine static server sending
+// Access-Control-Allow-Origin (see globalSetup); the hub (baseURL) reads it over
+// the network, so these tests exercise the browser's REAL CORS enforcement and
+// the production service worker — the things route mocks can't.
+
+const B_PORT = Number(process.env.ORIGIN_B_PORT ?? 4610);
+const ORIGIN_B = `http://localhost:${B_PORT}`;
+
+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("real two-origin federation (CORS + service worker)", () => {
+ test("reads a cross-origin archive over real CORS and shelves it", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await expect(
+ page.getByRole("heading", { name: "Your archives" }),
+ ).toBeVisible();
+ await addArchive(page, ORIGIN_B);
+ // The spine's title came from Origin B's /site.json, fetched cross-origin —
+ // it only renders if the real CORS read succeeded.
+ await expect(
+ page.getByTestId("shelf-spine").filter({ hasText: "Origin B Archive" }),
+ ).toBeVisible();
+ });
+
+ test("searches Origin B's transcripts across origins", async ({ page }) => {
+ await page.goto("/");
+ await addArchive(page, ORIGIN_B);
+ await expect(
+ page.getByTestId("shelf-spine").filter({ hasText: "Origin B Archive" }),
+ ).toBeVisible();
+
+ await page.getByTestId("query-builder").waitFor();
+ await page
+ .locator('[data-testid^="leaf-scope-"]')
+ .first()
+ .selectOption({ label: "Transcripts" });
+ const input = page.locator('[data-testid^="leaf-query-"]').first();
+ await input.click();
+ await input.fill("hydrogen");
+ await input.press("Enter");
+ await page.waitForURL(/[?&]qt=/);
+
+ // A hit for the video whose transcript cue contains "hydrogen" — this only
+ // works if the hub fetched Origin B's summaries AND transcript pages over
+ // CORS. Its slug is origin-qualified.
+ const result = page.locator("[data-result-slug]").first();
+ await expect(result).toBeVisible({ timeout: 15_000 });
+ await expect(result).toHaveAttribute(
+ "data-result-slug",
+ new RegExp(`localhost:${B_PORT}`),
+ );
+ });
+
+ test("service worker serves a cross-origin transcript shard offline", async ({
+ page,
+ context,
+ }) => {
+ await page.goto("/");
+ // The prod build registers the SW; wait until it actually controls the page
+ // (activate → clients.claim), so subsequent fetches route through it.
+ await page.waitForFunction(
+ () => !!navigator.serviceWorker?.controller,
+ undefined,
+ { timeout: 15_000 },
+ );
+
+ await addArchive(page, ORIGIN_B);
+ await expect(
+ page.getByTestId("shelf-spine").filter({ hasText: "Origin B Archive" }),
+ ).toBeVisible();
+
+ // Run a transcript search to make the hub fetch Origin B's transcript page
+ // cross-origin — the SW caches shard responses on first fetch.
+ await page.getByTestId("query-builder").waitFor();
+ await page
+ .locator('[data-testid^="leaf-scope-"]')
+ .first()
+ .selectOption({ label: "Transcripts" });
+ const input = page.locator('[data-testid^="leaf-query-"]').first();
+ await input.click();
+ await input.fill("hydrogen");
+ await input.press("Enter");
+ await expect(page.locator("[data-result-slug]").first()).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Go offline and fetch the cross-origin shard directly: the SW must serve it
+ // from cache (readable, CORS headers preserved) with no network.
+ await context.setOffline(true);
+ const shardUrl = `${ORIGIN_B}/transcripts/chan-b/page-0000.json`;
+ const body = await page.evaluate(async (url) => {
+ const res = await fetch(url);
+ return { ok: res.ok, text: await res.text() };
+ }, shardUrl);
+ expect(body.ok).toBe(true);
+ expect(body.text).toContain("hydrogen");
+ });
+});
diff --git a/export/e2e-2origin/globalSetup.ts b/export/e2e-2origin/globalSetup.ts
@@ -0,0 +1,157 @@
+import { execSync } from "node:child_process";
+import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import path from "node:path";
+
+// Global setup for the REAL two-origin test. Unlike the route-mocked e2e-hub
+// suite, this stands up two genuine static origins so the browser's real
+// same-origin / CORS enforcement and the prod service worker are exercised:
+//
+// Origin B (.2origin/originB) — a tiny hand-authored single-site archive: the
+// federation JSON contract (site.json + summaries + transcripts + subs)
+// plus a serve.json that sends Access-Control-Allow-Origin: * so a
+// cross-origin hub can read it. No Next build needed — the hub only ever
+// fetches Origin B's JSON, never its HTML.
+// Origin A (.2origin/hubA) — a real INSTANCE_MODE=hub production build (so its
+// service worker registers), with hub-sites.json emptied so only Origin B
+// is in play.
+//
+// Both are served by `serve` in the config's webServer array.
+
+const STAGE = path.resolve(process.cwd(), ".2origin");
+const ORIGIN_B = path.join(STAGE, "originB");
+const HUB_A = path.join(STAGE, "hubA");
+const GEN = "2026-01-01T00:00:00.000Z";
+
+function writeJson(rel: string, value: unknown) {
+ const p = path.join(ORIGIN_B, rel);
+ mkdirSync(path.dirname(p), { recursive: true });
+ writeFileSync(p, JSON.stringify(value));
+}
+
+function buildOriginB() {
+ rmSync(ORIGIN_B, { recursive: true, force: true });
+ writeJson("site.json", {
+ contract: 1,
+ siteId: "originb",
+ siteTitle: "Origin B Archive",
+ siteDescription: "A real federated archive served over CORS.",
+ headerTitle: "Origin B",
+ homeTagline: "",
+ accent: "#c026d3",
+ siteUrl: "",
+ pwa: false,
+ socialLinks: [],
+ groups: [{ id: "gb", name: "Origin B", selectedByDefault: true }],
+ defaultGroupId: "gb",
+ channels: [{ slug: "chan-b", name: "Bravo Channel", count: 2, groupId: "gb" }],
+ generatedAt: GEN,
+ summariesVersion: 3,
+ });
+ writeJson("summaries/manifest.json", {
+ version: 3,
+ totalCount: 2,
+ pageSize: 1000,
+ pageCount: 1,
+ generatedAt: GEN,
+ channels: [{ name: "Bravo Channel", count: 2, slug: "chan-b", groupId: "gb" }],
+ groups: [{ id: "gb", name: "Origin B", selectedByDefault: true }],
+ defaultGroupId: "gb",
+ siteId: "originb",
+ });
+ const sum = (id: string, title: string) => ({
+ slug: `chan-b/${id}`,
+ id,
+ channelSlug: "chan-b",
+ title,
+ uploadDate: "20260101",
+ date: "2026-01-01",
+ duration: "5:00",
+ channel: "Bravo Channel",
+ isLivestream: false,
+ ageRestricted: false,
+ isDeleted: false,
+ isUnlisted: false,
+ platform: "youtube",
+ webpageUrl: `https://example.com/${id}`,
+ });
+ writeJson("summaries/page-0000.json", [
+ sum("v1", "The Hydrogen Atom"),
+ sum("v2", "Helium and Beyond"),
+ ]);
+ writeJson("transcripts/chan-b/manifest.json", {
+ version: 1,
+ channelSlug: "chan-b",
+ pageCount: 1,
+ maxPageBytes: 8388608,
+ generatedAt: GEN,
+ slugToPage: { v1: 0, v2: 0 },
+ });
+ const det = (id: string, title: string, text: string) => ({
+ slug: `chan-b/${id}`,
+ id,
+ channelSlug: "chan-b",
+ title,
+ uploadDate: "20260101",
+ duration: 300,
+ channel: "Bravo Channel",
+ description: "",
+ tags: [],
+ isLivestream: false,
+ ageRestricted: false,
+ platform: "youtube",
+ webpageUrl: `https://example.com/${id}`,
+ cues: [{ start: 0, end: 5, text }],
+ });
+ writeJson("transcripts/chan-b/page-0000.json", [
+ det("v1", "The Hydrogen Atom", "the hydrogen atom has one proton"),
+ det("v2", "Helium and Beyond", "helium is a noble gas"),
+ ]);
+ writeJson("subs/manifest.json", {
+ version: 4,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: GEN,
+ });
+ // CORS on all JSON so a cross-origin hub can read this origin.
+ writeJson("serve.json", {
+ headers: [
+ {
+ source: "**/*.json",
+ headers: [{ key: "Access-Control-Allow-Origin", value: "*" }],
+ },
+ ],
+ });
+}
+
+function buildHub() {
+ // A real hub production build (SW registers only in prod). Skip the rebuild
+ // when a stage already exists unless TWO_ORIGIN_REBUILD=1 — the build is the
+ // slow part and the hub bundle rarely changes between local runs.
+ if (process.env.TWO_ORIGIN_REBUILD === "1") {
+ rmSync(HUB_A, { recursive: true, force: true });
+ }
+ try {
+ // Cheap existence probe: if hubA/sw.js is missing, (re)build.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ require("node:fs").accessSync(path.join(HUB_A, "sw.js"));
+ return;
+ } catch {
+ /* needs build */
+ }
+ execSync("pnpm run build:hub", { cwd: process.cwd(), stdio: "inherit" });
+ rmSync(HUB_A, { recursive: true, force: true });
+ cpSync(path.join(process.cwd(), "out"), HUB_A, { recursive: true });
+ // Only Origin B should be in play — drop the built-in prod pool (unreachable
+ // in the test) so the shelf isn't cluttered with failing origins.
+ writeFileSync(path.join(HUB_A, "hub-sites.json"), "[]");
+}
+
+// Stage both origins. Invoked from playwright.2origin.config.ts at module load
+// (NOT as Playwright globalSetup) because the served dirs must exist before the
+// webServer array starts, and Playwright launches webServers before globalSetup.
+export function stageTwoOrigins() {
+ mkdirSync(STAGE, { recursive: true });
+ buildOriginB();
+ buildHub();
+}
diff --git a/export/package.json b/export/package.json
@@ -20,6 +20,7 @@
"lint": "eslint",
"e2e": "playwright test",
"e2e:hub": "playwright test --config playwright.hub.config.ts",
+ "e2e:2origin": "playwright test --config playwright.2origin.config.ts",
"e2e:ui": "playwright test --ui",
"deploy": "pnpm dlx wrangler pages deploy out"
},
diff --git a/export/playwright.2origin.config.ts b/export/playwright.2origin.config.ts
@@ -0,0 +1,52 @@
+import path from "node:path";
+import { defineConfig, devices } from "@playwright/test";
+import { stageTwoOrigins } from "./e2e-2origin/globalSetup";
+
+// Real two-origin test: proves what the route-mocked e2e-hub suite can't —
+// genuine cross-origin CORS enforcement and the prod service worker offline
+// path. We stage Origin B (CORS JSON) and Origin A (a real hub build) HERE at
+// config load — before the webServer array starts — because Playwright launches
+// webServers before globalSetup. The two webServer entries then serve the staged
+// dirs, and the hub reads Origin B over the network like a deployed hub would.
+const STAGE = path.resolve(process.cwd(), ".2origin");
+const B_PORT = Number(process.env.ORIGIN_B_PORT ?? 4610);
+const A_PORT = Number(process.env.HUB_A_PORT ?? 4611);
+const baseURL = `http://localhost:${A_PORT}`;
+
+stageTwoOrigins();
+
+export default defineConfig({
+ testDir: "./e2e-2origin",
+ timeout: 60_000,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? "github" : "list",
+ outputDir: "test-results-2origin/",
+ fullyParallel: false,
+ workers: 1,
+ webServer: [
+ {
+ command: `pnpm exec serve ${path.join(STAGE, "originB")} -l ${B_PORT} --no-request-logging`,
+ url: `http://localhost:${B_PORT}/site.json`,
+ timeout: 60_000,
+ reuseExistingServer: !process.env.CI,
+ },
+ {
+ command: `pnpm exec serve ${path.join(STAGE, "hubA")} -l ${A_PORT} --no-request-logging`,
+ url: `${baseURL}/`,
+ timeout: 60_000,
+ reuseExistingServer: !process.env.CI,
+ },
+ ],
+ 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 } },
+ },
+ ],
+});