commit 5967fccab7f0e1225cde99a516b2d4b05a7421d1
parent 483487407374033ae6194ef85d336ec4dea8ad64
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:16:35 -0400
export: e2e-hub federated-search.spec — scope off/on across a reload, a failed member + Retry, a slow member, attribution
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 248 insertions(+), 0 deletions(-)
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -0,0 +1,248 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+
+// The hub's federated search, per archive: two official members (hub-sites.json)
+// served by route mocks WITH CORS, each with one video. Proves the scope chips
+// (take an archive out; it is not fetched, and the choice survives a reload),
+// per-archive state (a member whose pages cannot be read is "failed", the page
+// says "1 of 2 archives answered" with a Retry, and the healthy member's results
+// still render), Retry, progressive readiness (a slow member does not hold the
+// fast one's results back), and a card naming its source archive in text.
+
+const ORIGIN_A = "http://localhost:4598";
+const ORIGIN_B = "http://localhost:4599";
+const CORS = { "access-control-allow-origin": "*" };
+
+async function fulfillJson(route: Route, body: unknown) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: CORS,
+ body: JSON.stringify(body),
+ });
+}
+
+type Member = { origin: string; title: string; channel: string; video: string };
+const A: Member = { origin: ORIGIN_A, title: "Origin A", channel: "Channel A", video: "Alpha Video" };
+const B: Member = { origin: ORIGIN_B, title: "Origin B", channel: "Channel B", video: "Bravo Video" };
+
+function manifestOf(m: Member) {
+ return {
+ version: 3,
+ totalCount: 1,
+ pageSize: 1000,
+ pageCount: 1,
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ channels: [{ name: m.channel, count: 1, slug: "chan", groupId: "g" }],
+ groups: [{ id: "g", name: m.title, selectedByDefault: true }],
+ defaultGroupId: "g",
+ };
+}
+
+function pageOf(m: Member) {
+ const id = m === A ? "vida1" : "vidb1";
+ return [
+ {
+ slug: `chan/${id}`,
+ id,
+ channelSlug: "chan",
+ title: m.video,
+ uploadDate: m === A ? "20260102" : "20260101",
+ date: m === A ? "2026-01-02" : "2026-01-01",
+ duration: "5:00",
+ channel: m.channel,
+ isLivestream: false,
+ ageRestricted: false,
+ isDeleted: false,
+ isUnlisted: false,
+ platform: "youtube" as const,
+ webpageUrl: `${m.origin}/${id}`,
+ },
+ ];
+}
+
+// Every request to a member origin is counted; `pages` decides how its
+// summaries page is answered.
+type PageMode = "ok" | "abort" | "hold";
+type MemberMock = {
+ requests: string[];
+ pages: PageMode;
+ held: Route[];
+};
+
+async function mockMember(page: Page, m: Member): Promise<MemberMock> {
+ const mock: MemberMock = { requests: [], pages: "ok", held: [] };
+ await page.route(`${m.origin}/**`, async (route) => {
+ const url = new URL(route.request().url());
+ mock.requests.push(url.pathname);
+ if (url.pathname === "/summaries/manifest.json") {
+ return fulfillJson(route, manifestOf(m));
+ }
+ if (/^\/summaries\/page-\d+\.json$/.test(url.pathname)) {
+ if (mock.pages === "abort") return route.abort();
+ if (mock.pages === "hold") {
+ mock.held.push(route);
+ return;
+ }
+ return fulfillJson(route, pageOf(m));
+ }
+ if (url.pathname === "/subs/manifest.json") {
+ return fulfillJson(route, {
+ version: 4,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ });
+ }
+ // No posts, no aliases: a clean 404 (a member without a posts corpus is
+ // not a failure).
+ return route.fulfill({ status: 404, headers: CORS, body: "" });
+ });
+ return mock;
+}
+
+async function setup(page: Page) {
+ await page.route("**/hub-sites.json", (r) =>
+ fulfillJson(
+ r,
+ [A, B].map((m, i) => ({
+ siteId: `origin${i}`,
+ siteTitle: m.title,
+ siteUrl: m.origin,
+ pwa: false,
+ contract: 1,
+ })),
+ ),
+ );
+ await page.route("**/hub-summary.json", (r) =>
+ r.fulfill({ status: 404, body: "" }),
+ );
+ return { a: await mockMember(page, A), b: await mockMember(page, B) };
+}
+
+const chip = (page: Page, m: Member) =>
+ page.getByTestId(`hub-scope-chip-${m.origin}`);
+const resultFrom = (page: Page, m: Member) =>
+ page.locator(`[data-result-slug^="${m.origin}"]`);
+const status = (page: Page) => page.getByTestId("hub-scope-status");
+
+test.describe("hub federated search — scope, per-archive state, attribution", () => {
+ test("both official archives are searched by default and each card names its archive", async ({
+ page,
+ }) => {
+ await setup(page);
+ await page.goto("/");
+ await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(chip(page, B)).toContainText("Origin B");
+ await expect(chip(page, B).getByRole("button", { name: /Origin B/ })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ // Browse listing: one card per archive, each naming its source in text.
+ const b = resultFrom(page, B);
+ await expect(b).toHaveCount(1);
+ await expect(b.getByTestId("result-source")).toHaveText("Origin B");
+ await expect(b).toContainText("Channel B");
+ await expect(resultFrom(page, A).getByTestId("result-source")).toHaveText(
+ "Origin A",
+ );
+ // Nothing failed, so no "N of M" line.
+ await expect(status(page)).toHaveCount(0);
+ });
+
+ test("toggling an archive off removes its results and stops its fetches, across a reload", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ await page.goto("/");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+
+ await chip(page, B).getByRole("button", { name: /Origin B/ }).click();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "off");
+ await expect(
+ chip(page, B).getByRole("button", { name: /Origin B/ }),
+ ).toHaveAttribute("aria-pressed", "false");
+ await expect(resultFrom(page, B)).toHaveCount(0);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(page.getByText(/Searching 1 archive\b/)).toBeVisible();
+
+ // The choice is this browser's: after a reload Origin B stays off and is
+ // not fetched at all.
+ mocks.b.requests.length = 0;
+ await page.reload();
+ await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
+ await expect(chip(page, B)).toHaveAttribute("data-status", "off");
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(resultFrom(page, B)).toHaveCount(0);
+ expect(mocks.b.requests).toEqual([]);
+
+ // Back on: fetched, and its results return.
+ await chip(page, B).getByRole("button", { name: /Origin B/ }).click();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ expect(mocks.b.requests).toContain("/summaries/manifest.json");
+ });
+
+ test("a member that cannot be read fails alone; Retry brings it back", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ mocks.b.pages = "abort";
+ await page.goto("/");
+
+ await expect(chip(page, B)).toHaveAttribute("data-status", "failed");
+ await expect(chip(page, B)).toContainText("failed");
+ await expect(
+ chip(page, B).getByRole("button", { name: "Retry Origin B" }),
+ ).toBeVisible();
+ await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
+ await expect(status(page)).toContainText("1 of 2 archives answered.");
+ await expect(status(page)).toContainText("Origin B did not");
+ // The healthy member's results still render.
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(resultFrom(page, B)).toHaveCount(0);
+
+ // Fix the member, then Retry from the line.
+ mocks.b.pages = "ok";
+ await status(page).getByRole("button", { name: "Retry", exact: true }).click();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(status(page)).toHaveCount(0);
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ });
+
+ test("a slow member does not hold back a fast one, and a search re-runs as it arrives", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ mocks.b.pages = "hold";
+ await page.goto("/");
+
+ await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
+ await expect(chip(page, B)).toHaveAttribute("data-status", "loading");
+ await expect(chip(page, B)).toContainText("loading");
+
+ // Search while Origin B is still loading: Origin A answers now.
+ 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("video");
+ await input.press("Enter");
+ await page.waitForURL(/[?&]qt=/);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(resultFrom(page, B)).toHaveCount(0);
+
+ // Origin B arrives: the same search now covers it.
+ await expect.poll(() => mocks.b.held.length).toBeGreaterThan(0);
+ for (const r of mocks.b.held.splice(0)) await fulfillJson(r, pageOf(B));
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ });
+});