import { expect, test, type Page, type Route } from "@playwright/test"; import { ASK_SCOPE_LINE, LIVE_CHAT_MISSING, NO_ARCHIVES_IN_SCOPE, } from "../app/ask/hubScopeCopy"; import { showAll } from "../e2e/helpers"; // 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. // Release 10 (L1): a member with no live chat is ready on its first subs // answer (a 404 is an empty manifest), and a subs manifest that cannot be read // is retried once and never fails its archive (ready, without live chat); the // official archives follow the homepage's // order (hub-summary.json's) on the cards and the chips, and an archive with no // accent wears ONE colour — its homepage card's — on its card, its chip and its // results; /ask searches only the archives the chips leave in, and with every // archive switched off it says so and cannot ask. // Release 11 (O1): /ask says how many of the hub's archives it searches // (ASK_SCOPE_LINE), and is not ready until the hub's own list is in — the // archives a browser added are known at once, /hub-sites.json's a moment // later; a member whose live chat cannot be read says so on its chip // (LIVE_CHAT_MISSING), with a Retry for the live chat alone that keeps its // videos in the search while it runs. // Release 14 (S1): the results — the listing and the "N of M archives // answered" line with it — show only after the first Search of the page life, // so a spec that reads them presses Search first (`showAll`); the chips carry // each archive's state before that. 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" }; const C: Member = { origin: "http://localhost:4597", title: "Origin C", channel: "Channel C", video: "Charlie 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" : m === B ? "vidb1" : "vidc1"; 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, `subs` whether it has a subs manifest at all // ("missing": a 404), cannot serve it ("error": a 500) or answers only when // the test says so ("hold": parked in `heldSubs`). type PageMode = "ok" | "abort" | "hold"; type MemberMock = { requests: string[]; pages: PageMode; subs: "ok" | "missing" | "error" | "hold"; held: Route[]; heldSubs: Route[]; }; const SUBS_OK = { version: 4, channels: [], totalCount: 0, liveChatTotalCount: 0, generatedAt: "2026-01-01T00:00:00.000Z", }; async function mockMember(page: Page, m: Member): Promise { const mock: MemberMock = { requests: [], pages: "ok", subs: "ok", held: [], heldSubs: [], }; 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" && mock.subs === "error") { return route.fulfill({ status: 500, headers: CORS, body: "" }); } if (url.pathname === "/subs/manifest.json" && mock.subs === "hold") { mock.heldSubs.push(route); return; } if (url.pathname === "/subs/manifest.json" && mock.subs === "ok") { return fulfillJson(route, SUBS_OK); } // No posts, no aliases (and, when `subs` is "missing", no live chat): a // clean 404 (a member without that corpus is not a failure). return route.fulfill({ status: 404, headers: CORS, body: "" }); }); return mock; } // `summary`: the members hub-summary.json names, in its order (the homepage's); // absent, the file is a 404. async function setup( page: Page, members: Member[] = [A, B], { summary }: { summary?: Member[] } = {}, ) { const siteIdOf = (m: Member) => `origin${members.indexOf(m)}`; await page.route("**/hub-sites.json", (r) => fulfillJson( r, members.map((m) => ({ siteId: siteIdOf(m), siteTitle: m.title, siteUrl: m.origin, pwa: false, contract: 1, })), ), ); await page.route("**/hub-summary.json", (r) => summary ? fulfillJson(r, { version: 1, generatedAt: "2026-09-26T00:00:00.000Z", sites: summary.map((m, i) => ({ siteId: siteIdOf(m), siteTitle: m.title, siteUrl: m.origin, transcripts: 100 * (summary.length - i), })), }) : r.fulfill({ status: 404, body: "" }), ); const a = await mockMember(page, A); const b = await mockMember(page, B); const c = members.includes(C) ? await mockMember(page, C) : null; return { a, b, c }; } 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", ); // Both archives are in, and nothing has been asked: no results area yet, // and the bar says what to do. await expect(page.getByTestId("results-summary")).toHaveCount(0); await expect(resultFrom(page, A)).toHaveCount(0); await expect(resultFrom(page, B)).toHaveCount(0); await expect(page.getByText("Press Enter or click Search to apply")).toBeVisible(); await showAll(page); await expect(page.getByTestId("results-summary")).toHaveText("All videos (2)"); // 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 showAll(page); 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 showAll(page); 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 showAll(page); 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)).toHaveAttribute("role", "status"); 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); }); test("the answered line waits until every archive in scope has settled", async ({ page, }) => { const mocks = await setup(page, [A, B, C]); mocks.b.pages = "abort"; mocks.c!.pages = "hold"; await page.goto("/"); await showAll(page); await expect(chip(page, B)).toHaveAttribute("data-status", "failed"); await expect(chip(page, C)).toHaveAttribute("data-status", "loading"); // Origin C is neither answered nor failed yet: no count that leaves it out. await expect(status(page)).toHaveCount(0); await expect(resultFrom(page, A)).toHaveCount(1); await expect.poll(() => mocks.c!.held.length).toBeGreaterThan(0); for (const r of mocks.c!.held.splice(0)) await fulfillJson(r, pageOf(C)); await expect(chip(page, C)).toHaveAttribute("data-status", "ready"); await expect(status(page)).toContainText("2 of 3 archives answered."); await expect(status(page)).toContainText("Origin B did not"); await expect(resultFrom(page, C)).toHaveCount(1); }); test("a member with no live chat is ready on its first subs answer: a 404 is not retried", async ({ page, }) => { const mocks = await setup(page); mocks.b.subs = "missing"; await page.goto("/"); await showAll(page); // Ready means every feed of the archive is in, subs included. A 404 is an // empty subs manifest, at once. It used to be an error, retried after ~1 s, // so the chip turned ready with two requests on record; now there is one. await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); await expect(resultFrom(page, B)).toHaveCount(1); const subsAsks = (m: MemberMock) => m.requests.filter((p) => p === "/subs/manifest.json").length; expect(subsAsks(mocks.b)).toBe(1); expect(subsAsks(mocks.a)).toBe(1); }); test("the official archives follow the homepage's order, each in one colour on its card, chip and results", async ({ page, }) => { // hub-sites.json lists A then B; the summary (the homepage's order, // transcripts first) lists B then A. Neither sets an accent. await setup(page, [A, B], { summary: [B, A] }); await page.goto("/"); await showAll(page); const cards = page.getByTestId("shelf-spine"); await expect(cards).toHaveCount(2); await expect(cards.nth(0)).toContainText("Origin B"); await expect(cards.nth(1)).toContainText("Origin A"); const chips = page.locator('[data-testid^="hub-scope-chip-"]'); await expect(chips).toHaveCount(2); await expect(chips.nth(0)).toHaveAttribute("data-testid", `hub-scope-chip-${B.origin}`); await expect(chips.nth(1)).toHaveAttribute("data-testid", `hub-scope-chip-${A.origin}`); await expect(resultFrom(page, A)).toHaveCount(1); await expect(resultFrom(page, B)).toHaveCount(1); // The colour a CSS colour resolves to on this page. const resolve = (css: string) => page.evaluate((c) => { const el = document.createElement("span"); el.style.backgroundColor = c; document.body.append(el); const out = getComputedStyle(el).backgroundColor; el.remove(); return out; }, css); const coloursOf = async (m: Member) => ({ card: await cards .filter({ hasText: m.title }) .locator(":scope > span[aria-hidden='true']") .evaluate((el) => getComputedStyle(el).backgroundColor), dot: await chip(page, m) .locator("span.rounded-full") .evaluate((el) => getComputedStyle(el).backgroundColor), result: await resultFrom(page, m).evaluate( (el) => getComputedStyle(el).borderLeftColor, ), }); // Each wears seriesColor() at its summary index — its homepage card's // colour — on all three surfaces: B the first series colour, A the second. const first = await resolve("var(--chart-1)"); const second = await resolve("var(--chart-2)"); expect(first).not.toBe(second); expect(await coloursOf(B)).toEqual({ card: first, dot: first, result: first }); expect(await coloursOf(A)).toEqual({ card: second, dot: second, result: second }); }); test("/ask searches only the archives the chips leave in", async ({ page }) => { const mocks = await setup(page); await page.goto("/"); await chip(page, B).getByRole("button", { name: /Origin B/ }).click(); await expect(chip(page, B)).toHaveAttribute("data-status", "off"); // A fresh page life on /ask: the scope is this browser's, so Origin B is // not fetched there either, and the chat is ready once Origin A is in. // (An empty scope is never ready on /ask — not even for the instant before // the hub's list loads — so "ready" below means Origin A is in.) const ready = page.getByPlaceholder(/^Ask about the transcripts/); mocks.a.requests.length = 0; mocks.b.requests.length = 0; await page.goto("/ask"); await expect .poll(() => mocks.a.requests) .toContain("/summaries/manifest.json"); await expect(ready).toBeVisible(); expect(mocks.a.requests).toContain("/summaries/page-0000.json"); expect(mocks.b.requests).toEqual([]); // And it says so: one line, its ${link()} phrase a link to the chips. const scope = page.getByTestId("ask-scope"); const oneOfTwo = ASK_SCOPE_LINE(1, 2); if (oneOfTwo === null) { await expect(scope).toHaveCount(0); } else { await expect(scope).toHaveText(oneOfTwo.text); const { link } = oneOfTwo; if (link) { await expect( scope.getByRole("link", { name: link, exact: true }), ).toHaveAttribute("href", "/"); } } // Back on (on the front page), /ask reads it again. await page.goto("/"); await chip(page, B).getByRole("button", { name: /Origin B/ }).click(); await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); mocks.b.requests.length = 0; await page.goto("/ask"); await expect .poll(() => mocks.b.requests) .toContain("/summaries/manifest.json"); await expect(ready).toBeVisible(); expect(mocks.b.requests).toContain("/summaries/page-0000.json"); const twoOfTwo = ASK_SCOPE_LINE(2, 2); if (twoOfTwo === null) await expect(scope).toHaveCount(0); else await expect(scope).toHaveText(twoOfTwo.text); }); test("/ask is not ready until the hub's own list is in, even with an added archive ready", async ({ page, }) => { // Origin B is an archive this browser added (read from storage at once); // Origin A is the hub's own, from /hub-sites.json — held here. const mocks = await setup(page, [A]); const heldList: Route[] = []; await page.route("**/hub-sites.json", (r) => { heldList.push(r); }); await page.addInitScript((origin) => { window.localStorage.setItem( "ytdlp-tb:hub-sites", JSON.stringify([ { origin, siteId: "added-b", siteTitle: "Origin B", pwa: false, kind: "external", contract: 1, addedAt: 1, }, ]), ); }, B.origin); await page.goto("/ask"); // Origin B is fetched meanwhile — every feed of it answered... await expect .poll(() => mocks.b.requests) .toEqual( expect.arrayContaining([ "/summaries/manifest.json", "/summaries/page-0000.json", "/subs/manifest.json", "/posts/manifest.json", ]), ); await expect.poll(() => heldList.length).toBeGreaterThan(0); // ...but it is not the hub: no question may ground in it alone. Give a // ready provider time to show itself, then check it did not. await page.waitForTimeout(1_000); await expect(page.getByPlaceholder("Loading transcripts…")).toBeVisible(); await expect(page.getByTestId("ask-blocked")).toHaveCount(0); await expect(page.getByTestId("ask-scope")).toHaveCount(0); // The hub's list arrives: Origin A is fetched, and the chat is ready over // both. for (const r of heldList.splice(0)) { await fulfillJson(r, [ { siteId: "origin0", siteTitle: A.title, siteUrl: A.origin, pwa: false, contract: 1 }, ]); } await expect .poll(() => mocks.a.requests) .toContain("/summaries/page-0000.json"); await expect( page.getByPlaceholder(/^Ask about the transcripts/), ).toBeVisible(); const both = ASK_SCOPE_LINE(2, 2); if (both !== null) { await expect(page.getByTestId("ask-scope")).toHaveText(both.text); } }); test("a subs manifest that cannot be read is retried once and never fails its archive", async ({ page, }) => { const mocks = await setup(page); mocks.b.subs = "error"; await page.goto("/"); await showAll(page); // Live chat is the auxiliary layer: after one retry the error counts as // settled, so Origin B is ready — its videos searched, without live chat — // and nothing says it did not answer. await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); await expect(resultFrom(page, B)).toHaveCount(1); await expect(resultFrom(page, A)).toHaveCount(1); await expect( chip(page, B).getByRole("button", { name: "Retry Origin B", exact: true }), ).toHaveCount(0); await expect(status(page)).toHaveCount(0); // Ready waits for the retry to settle, so the count is final here. expect( mocks.b.requests.filter((p) => p === "/subs/manifest.json").length, ).toBe(2); }); test("a member's missing live chat is noted on its chip, and its Retry refetches the live chat alone", async ({ page, }) => { const mocks = await setup(page); mocks.b.subs = "error"; await page.goto("/"); await showAll(page); await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); await expect(resultFrom(page, B)).toHaveCount(1); // The note, on Origin B's chip only, with its own Retry. const note = chip(page, B).getByTestId("hub-scope-live-chat"); await expect(note).toHaveText(LIVE_CHAT_MISSING); await expect(chip(page, A).getByTestId("hub-scope-live-chat")).toHaveCount(0); const retryChat = chip(page, B).getByRole("button", { name: "Retry live chat from Origin B", exact: true, }); await expect(retryChat).toBeEnabled(); // Retry, answered only when the test says: while it is in flight Origin B // stays ready and its video stays in the results, the note stays with its // Retry disabled, and nothing but the live chat is asked for again. mocks.b.subs = "hold"; const asked = mocks.b.requests.length; await retryChat.click(); await expect.poll(() => mocks.b.heldSubs.length).toBe(1); await expect(retryChat).toBeDisabled(); await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); await expect(note).toHaveText(LIVE_CHAT_MISSING); await expect(resultFrom(page, B)).toHaveCount(1); await expect(resultFrom(page, A)).toHaveCount(1); expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]); // It answers: the note and its Retry go; the archive never left. for (const r of mocks.b.heldSubs.splice(0)) await fulfillJson(r, SUBS_OK); await expect(note).toHaveCount(0); await expect(retryChat).toHaveCount(0); await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); await expect(resultFrom(page, B)).toHaveCount(1); expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]); }); test("/ask is not ready while it refetches a live chat that failed on the front page", async ({ page, }) => { // Origin B's live chat failed on the front page: its archive is ready // there, noted as missing its live chat. const mocks = await setup(page); mocks.b.subs = "error"; await page.goto("/"); await expect(chip(page, B).getByTestId("hub-scope-live-chat")).toHaveText( LIVE_CHAT_MISSING, ); // To /ask in the same page life (the same query client): its own mount // asks for the failed live chat again. Held, /ask is not ready — a question // now would miss live chat that may be about to arrive. mocks.b.subs = "hold"; await page .getByRole("banner") .getByRole("link", { name: "Ask AI", exact: true }) .click(); await page.waitForURL(/\/ask\/?$/); await expect.poll(() => mocks.b.heldSubs.length).toBe(1); await page.waitForTimeout(1_000); await expect(page.getByPlaceholder("Loading transcripts…")).toBeVisible(); // It answers: /ask is ready, over both archives and B's live chat. for (const r of mocks.b.heldSubs.splice(0)) await fulfillJson(r, SUBS_OK); await expect( page.getByPlaceholder(/^Ask about the transcripts/), ).toBeVisible(); }); test("/ask with every archive switched off says so and cannot ask; with one back on, it asks", async ({ page, }) => { const mocks = await setup(page); // The AI provider, in Scripted mode: every call is one SSE text reply, and // every question it is sent is recorded. "Asks" means the question reaches // the provider — these member mocks serve no transcript tree, so the chat's // own search over them never finishes and no answer is awaited. const AI_CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "*", }; const sent: string[] = []; await page.route("https://api.anthropic.com/**", async (route) => { if (route.request().method() === "OPTIONS") { return route.fulfill({ status: 204, headers: AI_CORS }); } sent.push(route.request().postData() ?? ""); const text = "DONE"; await route.fulfill({ status: 200, headers: { ...AI_CORS, "content-type": "text/event-stream" }, body: [ `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text } })}\n\n`, `data: ${JSON.stringify({ type: "message_stop" })}\n\n`, ].join(""), }); }); await page.goto("/"); for (const m of [A, B]) { await chip(page, m).getByRole("button", { name: new RegExp(m.title) }).click(); await expect(chip(page, m)).toHaveAttribute("data-status", "off"); } // Nothing in scope: one line says why, the box is disabled, and nothing is // fetched from either archive. mocks.a.requests.length = 0; mocks.b.requests.length = 0; await page.goto("/ask"); const composer = page .locator("form") .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) }); const blocked = composer.getByTestId("ask-blocked"); await expect(blocked).toHaveText(NO_ARCHIVES_IN_SCOPE.text); await expect(page.getByTestId("ask-blocked")).toHaveCount(1); // Its ${link()} phrase (when the copy has one) links to the front page, // where the chips are. const { link } = NO_ARCHIVES_IN_SCOPE; if (link) { await expect( blocked.getByRole("link", { name: link, exact: true }), ).toHaveAttribute("href", "/"); } await expect(composer.locator("textarea")).toBeDisabled(); await expect( composer.getByRole("button", { name: "Ask", exact: true }), ).toBeDisabled(); await expect(page.getByPlaceholder("Loading transcripts…")).toHaveCount(0); expect(mocks.a.requests).toEqual([]); expect(mocks.b.requests).toEqual([]); // Origin A back on: the line goes, and a question is asked and answered. await page.goto("/"); await chip(page, A).getByRole("button", { name: /Origin A/ }).click(); await expect(chip(page, A)).toHaveAttribute("data-status", "ready"); await page.goto("/ask"); await page.getByRole("button", { name: "Scripted" }).click(); const key = page.locator('input[placeholder^="sk-ant"]'); await key.fill("sk-ant-test"); await expect(key).toHaveValue("sk-ant-test"); await expect .poll(() => mocks.a.requests) .toContain("/summaries/manifest.json"); const box = page.getByPlaceholder(/^Ask about the transcripts/); await expect(box).toBeEnabled(); await expect(page.getByTestId("ask-blocked")).toHaveCount(0); await box.fill("what did they say?"); await page.getByRole("button", { name: "Ask", exact: true }).click(); await expect .poll(() => sent.some((body) => body.includes("what did they say?"))) .toBe(true); }); });