import { expect, test, type Page } from "@playwright/test"; import { CHANNEL_SLUG, VIDEO_CHAT_LARGE, VIDEO_CHAT_SMALL, VIDEO_TRANSCRIPT_ONLY, } from "./fixtures/data"; import { installRoutes } from "./helpers"; // Composite-search query tree e2e tests. // // Each test seeds the search via the `?qt=` URL parameter (compact JSON of // the QueryNode tree) so we don't have to script the builder UI on every // scenario. UI-interaction tests (cache-hit, URL round-trip from typing) // still drive the builder directly. type SLeaf = { k: "l"; i?: string; q: string; s: "transcripts" | "chat" | "metadata" | "description" | "tags"; r?: 1; h?: 0; n?: 1; }; type SGroup = { k: "g"; i?: string; o: "AND" | "OR"; n?: 1; c: (SLeaf | SGroup)[]; }; function qt(root: SGroup): string { return encodeURIComponent(JSON.stringify(root)); } const TRANSCRIPT_ONLY_SLUG = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`; const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`; const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`; async function expectResultSlugs(page: Page, slugs: string[]) { // Wait until the search has settled. We count unique cards by selecting // card-header rows only — leaf-header and hit rows also carry // `data-result-slug` (for compound selectors) but would over-count here. const cards = page.locator("[data-card-header]"); await expect(async () => { const got = await cards.evaluateAll((els) => els.map((e) => e.getAttribute("data-result-slug") ?? ""), ); expect(got.slice().sort()).toEqual(slugs.slice().sort()); }).toPass({ timeout: 15_000 }); } test.describe("composite search — query tree", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); }); test("single leaf: transcripts:'alpha' matches all three fixture videos", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "alpha", s: "transcripts" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); // Each result card carries a per-leaf hit section with the leaf's // search term shown in monospace. await expect( page.locator("[data-leaf-section]").first(), ).toBeVisible(); }); test("AND narrows: transcripts:'small chat' AND chat:'message' → only VIDEO_CHAT_SMALL", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "small chat", s: "transcripts" }, { k: "l", q: "message", s: "chat" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); // Both leaves contributed hits — there should be two leaf-section bars. const sections = page.locator( `[data-result-slug="${CHAT_SMALL_SLUG}"] [data-leaf-section]`, ); await expect(sections).toHaveCount(2); }); test("OR widens: chat:'message' OR chat:'#0' surfaces both chat videos", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [ { k: "g", o: "OR", c: [ { k: "l", q: "message", s: "chat" }, { k: "l", q: "(#0)", s: "chat" }, ], }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]); }); test("NOT excludes: transcripts:'alpha' AND NOT chat:'message' → transcript_only + large_chat", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "alpha", s: "transcripts" }, { k: "l", q: "message", s: "chat", n: 1 }, ], }; await page.goto(`/?qt=${qt(tree)}`); // VIDEO_CHAT_SMALL has "message" in chat — excluded. // VIDEO_CHAT_LARGE has chat but no "message" word — kept. // VIDEO_TRANSCRIPT_ONLY has no chat at all, so "not in chat:message" — kept. await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG, CHAT_LARGE_SLUG]); }); test("hit-contribution toggle: filter-only leaf does not surface its hits", async ({ page, }) => { // chat leaf is a pure filter (h:0); only the transcripts leaf // contributes hits. The matched video should have exactly one // leaf-section (transcripts), not two. const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "small chat", s: "transcripts" }, { k: "l", q: "message", s: "chat", h: 0 }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); const sections = page.locator( `[data-result-slug="${CHAT_SMALL_SLUG}"] [data-leaf-section]`, ); await expect(sections).toHaveCount(1); }); test("metadata leaf: title-scope matches by title text", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "Transcript only", s: "metadata" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG]); }); test("description leaf: matches a term that lives only in the description", async ({ page, }) => { // "zebra" appears only in VIDEO_TRANSCRIPT_ONLY's description — not in any // title or transcript — so this isolates the new description scope. const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "zebra", s: "description" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG]); // And a transcript-scope search for the same term finds nothing. const tTree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "zebra", s: "transcripts" }], }; await page.goto(`/?qt=${qt(tTree)}`); await expect(page.getByText("No matching videos.")).toBeVisible({ timeout: 15_000, }); }); test("tags leaf: matches a term that lives only in a tag", async ({ page, }) => { // "gaming" is a tag only on VIDEO_CHAT_LARGE — not in any title/transcript. const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "gaming", s: "tags" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [CHAT_LARGE_SLUG]); }); test("a leaf section is labelled with its own scope", async ({ page }) => { // The section bar over a card's hits used to be a four-way ternary that // predated the description and tags scopes, so hits from either were // filed under "Transcripts" — the one place they demonstrably did not // come from. It reads the builder's own table now, which is also why // `tags` says "Keywords": the curated vocabulary is what "Tags" means. const tree: SGroup = { k: "g", o: "OR", c: [ { k: "l", q: "zebra", s: "description" }, { k: "l", q: "gaming", s: "tags" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG, CHAT_LARGE_SLUG]); await expect( page.locator( `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, ), ).toContainText("Description"); await expect( page.locator( `[data-result-slug="${CHAT_LARGE_SLUG}"] [data-leaf-section]`, ), ).toContainText("Keywords"); // Neither says Transcripts, which is what the old chain said for both. // textContent, not innerText: the bar is CSS-uppercased. const sections = page.locator("[data-leaf-section]"); await expect(sections).toHaveCount(2); expect((await sections.allTextContents()).join(" ")).not.toContain( "Transcripts", ); }); test("legacy URL: ?q=alpha auto-migrates to a single-leaf transcripts query", async ({ page, }) => { await page.goto(`/?q=alpha`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); }); test("legacy URL: ?q=message&m=subs migrates to a single-leaf chat query", async ({ page, }) => { await page.goto(`/?q=message&m=subs`); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); }); test("URL round-trip: builder reflects qt= tree on load and Share writes qt", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "small chat", s: "transcripts" }, { k: "l", q: "message", s: "chat" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); // Two leaf inputs are rendered in the builder. const leafInputs = page.locator('input[data-testid^="leaf-query-"]'); await expect(leafInputs).toHaveCount(2); await expect(leafInputs.nth(0)).toHaveValue("small chat"); await expect(leafInputs.nth(1)).toHaveValue("message"); }); test("cache: editing one leaf re-runs only that leaf; sibling shows 'cached' badge", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "small chat", s: "transcripts" }, { k: "l", q: "message", s: "chat" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); // Edit the chat leaf's query, then re-submit. The transcripts leaf's // (canonical-hash, scope-hash) is unchanged, so it hits the layer cache // and renders the `cached` badge. const leafInputs = page.locator('input[data-testid^="leaf-query-"]'); const chatInput = leafInputs.nth(1); await chatInput.fill("@user0"); await page.getByTestId("search-submit").click(); // After the re-run settles, the transcript-only leaf should be cached. await expect(page.locator("[data-cache-hit]").first()).toBeVisible({ timeout: 15_000, }); }); test("hit-contribution: toggling 'Show hits' on a leaf surfaces hits (no stale cache)", async ({ page, }) => { // Regression: canonicalString must include contributeHits in the leaf // hash. If it doesn't, two leaves that differ only in `Show hits` share // a cache key — the hits-off entry (empty hit map) poisons later // hits-on reads, so the result list shows slugs but zero hit rows. We // load a two-leaf AND where the first leaf is h:0, let the pipeline // cache it, then flip the checkbox to h:1 and assert hit rows appear. // (Two leaves to escape compact mode so the "Show hits" checkbox is // accessible.) const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "alpha", s: "transcripts", h: 0 }, { k: "l", q: "gamma", s: "transcripts" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); // Only the gamma leaf contributes hits — 1 leaf-section per matched video. await expect( page.locator( `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, ), ).toHaveCount(1); // Flip the alpha leaf's "Show hits" checkbox on and re-commit. (The kit // Checkbox renders a role="checkbox" button, not an — match by // testid prefix; Playwright's check() drives the ARIA checkbox.) const showHits = page.locator('[data-testid^="leaf-hits-"]').first(); await showHits.check(); await page.getByTestId("search-submit").click(); // Now alpha contributes hits too — every matched video should show two // leaf-section bars (one per contributing leaf). await expect( page.locator( `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, ), ).toHaveCount(2, { timeout: 15_000 }); }); test("collapse to one layer resets the hidden 'Show hits' option", async ({ page, }) => { // Regression: "Show hits in results" (contributeHits) is hidden in the // builder's compact single-layer view. Deselecting it on a layer and then // deleting the other layers used to leave the lone layer stuck hits-off // with no control to turn it back on. Collapsing to compact now resets it. // Seed a two-leaf AND where the first (alpha) is hits-off, the second // (gamma) plain — so only gamma contributes hits to start. const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "alpha", s: "transcripts", h: 0 }, { k: "l", q: "gamma", s: "transcripts" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); // Only gamma contributes hits → one leaf-section per matched video. await expect( page.locator( `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, ), ).toHaveCount(1); // Delete the second leaf (gamma); alpha is now the only layer, so the // builder collapses into compact mode. await page.locator('[data-testid^="leaf-delete-"]').nth(1).click(); await expect(page.getByTestId("compact-add-layer")).toBeVisible(); // Commit. alpha had hits off, but collapsing reset contributeHits → it now // contributes hits as the sole layer (one leaf-section per matched video). await page.getByTestId("search-submit").click(); await expect( page.locator( `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, ), ).toHaveCount(1, { timeout: 15_000 }); }); test("unwrap: wrapping then unwrapping a leaf restores the original tree", async ({ page, }) => { // Two leaves so the builder isn't in compact mode (the `wrap` button is // only rendered in the full leaf card). const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "alpha", s: "transcripts" }, { k: "l", q: "beta", s: "transcripts" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); // Wrap the first leaf in an AND sub-group, then unwrap it. await page.getByTestId(/^leaf-wrap-/).first().click(); const innerGroupUnwrap = page.getByTestId(/^group-unwrap-/); await expect(innerGroupUnwrap).toBeVisible(); await innerGroupUnwrap.click(); await expect(page.getByTestId(/^group-unwrap-/)).toHaveCount(0); // Both leaf inputs are now direct children of the root group again. const leafInputs = page.locator('input[data-testid^="leaf-query-"]'); await expect(leafInputs).toHaveCount(2); await expect(leafInputs.nth(0)).toHaveValue("alpha"); await expect(leafInputs.nth(1)).toHaveValue("beta"); }); test("unwrap (negated): each promoted child inherits the group's NOT", async ({ page, }) => { // Inner group `(metadata:'Transcript only')` flipped to NOT — wraps the // leaf in an AND sub-group, then unwraps. The child leaf should come // out with its own `negate` flipped. const tree: SGroup = { k: "g", o: "AND", c: [ { k: "l", q: "alpha", s: "transcripts" }, { k: "g", o: "AND", n: 1, c: [{ k: "l", q: "Transcript only", s: "metadata" }], }, ], }; await page.goto(`/?qt=${qt(tree)}`); // alpha AND NOT (title contains "Transcript only") → small + large chat. await expectResultSlugs(page, [CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]); // Unwrap the negated inner group. const innerUnwrap = page.getByTestId(/^group-unwrap-/); await expect(innerUnwrap).toHaveCount(1); await innerUnwrap.click(); // The metadata leaf should now be a direct child of root with its // `negate` checkbox checked (De Morgan applied locally). await expect(page.getByTestId(/^group-unwrap-/)).toHaveCount(0); const negateBoxes = page.locator('[data-testid^="leaf-negate-"]'); // The transcripts leaf and the metadata leaf are now both root-level // children. The metadata leaf is the negated one. (Kit Checkbox = a // role="checkbox" button, so read aria-checked rather than input.checked.) const checkedStates = await negateBoxes.evaluateAll((els) => els.map((e) => e.getAttribute("aria-checked") === "true"), ); expect(checkedStates.filter(Boolean).length).toBe(1); }); test("cache survives leaf-id changes across page reloads (regression)", async ({ page, }) => { // Regression: LayerHits carry a session-scoped `leafId`. The layer cache // stores hits verbatim, so a reload (which resets the id counter and // re-allocates ids via parseRoot) used to surface stale ids — the // renderer buckets hits by leafId, so every cached hit was silently // dropped while the total-hits count (computed before bucketing) stayed // correct. Build the AND via the UI so the counter advances past where // parseRoot lands, then reload and assert hits still render. await page.goto("/"); const firstInput = page .locator('input[data-testid^="leaf-query-"]') .first(); await firstInput.fill("alpha"); await page.getByTestId("compact-add-layer").click(); const inputs = page.locator('input[data-testid^="leaf-query-"]'); await inputs.nth(1).fill("gamma"); await page.getByTestId("search-submit").click(); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); await expect( page.locator( `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, ), ).toHaveCount(2); await page.reload(); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); await expect( page.locator( `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, ), ).toHaveCount(2, { timeout: 15_000 }); }); test("the live search auto-grounds the /ask chat via the Results⇄Chat nav", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "alpha", s: "transcripts" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, ]); // Cross to the chat via the shared workspace nav (the search stays mounted), // and it auto-grounds in the three live results — no hand-off button. await page .getByTestId("workspace-nav") .getByRole("link", { name: "Chat" }) .click(); await expect(page).toHaveURL(/\/ask\//); await expect(page.getByText(/Grounded in 3 results/)).toBeVisible(); // Scope to the chat pane: the results rail (mounted but hidden in Chat view) // also renders the query term, so an unscoped .first() would hit the hidden one. await expect( page.getByTestId("pane-chat").getByText(/alpha/).first(), ).toBeVisible(); }); test("builder UI: + Add layer adds a second leaf and commits on Search", async ({ page, }) => { await page.goto("/"); // Start in compact mode: one leaf, "+ Add layer" pill below. const compactAdd = page.getByTestId("compact-add-layer"); await expect(compactAdd).toBeVisible(); // Fill the first leaf via its query input. const firstInput = page.locator('input[data-testid^="leaf-query-"]').first(); await firstInput.fill("small chat"); await compactAdd.click(); // Now there should be two leaves (the builder switched to the full // group rendering). const inputs = page.locator('input[data-testid^="leaf-query-"]'); await expect(inputs).toHaveCount(2); await inputs.nth(1).fill("message"); // The new leaf defaults to scope=transcripts; switch it to chat. const scopeSelects = page.locator('select[data-testid^="leaf-scope-"]'); await scopeSelects.nth(1).selectOption("chat"); await page.getByTestId("search-submit").click(); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); }); });