Archilyzer · Source

archilyzer

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

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:
Aexport/e2e-hub/federated-search.spec.ts | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); + }); +});