import { expect, test, type Page, type Route } from "@playwright/test"; import { NO_ARCHIVES_ON_HUB } from "../app/ask/hubScopeCopy"; // The hub's /ask route. // // Hub mode bypasses the workspace shell that mounts the search session for a // single site, so AskHub has to supply its own — AskChat's retrieval reads the // committed query tree and filters out of it. When it did not, `next build` // with INSTANCE_MODE=hub threw while prerendering /ask ("useSearchSession must // be used within a SearchSessionProvider") and took the whole route with it. // This spec is the cheap guard on the rendered page; the build itself is the // other half, covered by the 2-origin suite which builds the hub for real. // No built-in pool: this route has to stand up on a hub with no archives, // which is what a fresh hub is. `answer` is how /hub-sites.json replies: an // empty list, or a failure (the registry never asks twice). async function stubBuiltins( page: Page, answer: "empty" | "error" | "abort" = "empty", ) { await page.route("**/hub-sites.json", (r: Route) => answer === "abort" ? r.abort() : r.fulfill({ status: answer === "error" ? 500 : 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, body: answer === "error" ? "" : "[]", }), ); } test.describe("hub /ask", () => { test("renders the chat with its composer", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (e) => errors.push(String(e))); await stubBuiltins(page); await page.goto("/ask"); // The route's own header — hub mode says "the federation", not // "the transcripts". await expect( page.getByRole("heading", { name: /Ask a question about the federation/ }), ).toBeVisible(); // The composer is AskChat's whole point, and it only renders once the // provider stack AskHub now mounts is there. On a hub with no archives it // is there but blocked (below), so it is found by its form. const composer = page .locator("form") .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) }); await expect(composer.locator("textarea")).toBeVisible(); await expect( page.getByRole("button", { name: "Ask", exact: true }), ).toBeVisible(); // A missing provider surfaces as a client-side throw, not a blank page. expect(errors).toEqual([]); }); // Release 11 (O1): a hub with no archives at all used to read "Loading // transcripts…" forever on /ask. Once /hub-sites.json has answered — an // empty list, or a failure the registry does not retry — and this browser // has added none, the composer says so instead (NO_ARCHIVES_ON_HUB). for (const answer of ["empty", "error", "abort"] as const) { test(`a hub with no archives (hub-sites.json ${answer}) says so and cannot ask`, async ({ page, }) => { await stubBuiltins(page, answer); 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_ON_HUB.text); await expect(blocked).toHaveAttribute("role", "status"); // Its ${link()} phrase (when the copy has one) links to the front page, // where the form to add an archive is. const { link } = NO_ARCHIVES_ON_HUB; 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); // No archives, so no "Searching N of M" line either. await expect(page.getByTestId("ask-scope")).toHaveCount(0); }); } });