import { expect, test, type Page } from "@playwright/test"; import { CHANNEL_SLUG, TAG_COLLAB, POST_CHANNEL_SLUG, POST_REPLY_ID, POST_ROOT_ID, VIDEO_CHAT_LARGE, VIDEO_CHAT_SMALL, VIDEO_TRANSCRIPT_ONLY, } from "./fixtures/data"; import { installRoutes, installTagRoutes, openFilters, showAll } from "./helpers"; // Release 16, slice CK: the Filters panel's "Search in" row — Transcripts, // Posts, Live chat — says what a plain query (a "transcripts" leaf) reads. // Transcripts and Posts are on by default, Live chat off; a leaf asked for by // name in the builder is not the row's business; an empty query still lists // what the Type row says. // // The fixture: every video's cues say "… — alpha line" (and beta, gamma); two // posts say "kappa", which no video does; only VIDEO_CHAT_SMALL's live chat // says "message". const STORAGE_KEY = "ytdlp-tb:export-filters"; 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}`; const VIDEO_SLUGS = [TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]; const POST_SLUGS = [ `${POST_CHANNEL_SLUG}/${POST_ROOT_ID}`, `${POST_CHANNEL_SLUG}/${POST_REPLY_ID}`, ]; const row = (page: Page) => page.getByTestId("search-in-row"); const box = (page: Page, name: "Transcripts" | "Posts" | "Live chat") => row(page).getByRole("checkbox", { name, exact: true }); const leafInput = (page: Page) => page.locator('input[data-testid^="leaf-query-"]').first(); async function expectResultSlugs(page: Page, slugs: string[]) { 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 }); } async function search(page: Page, q: string) { await leafInput(page).fill(q); await page.getByTestId("search-submit").click(); } // The search has finished: the progress line has lost its ellipsis. async function expectFinished(page: Page) { await expect(page.getByText(/^searched \d+\/\d+$/)).toBeVisible({ timeout: 15_000 }); } test.describe("Search in", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); }); test("by default Transcripts and Posts are ticked and Live chat is not", async ({ page, }) => { await page.goto("/"); await openFilters(page); await expect(box(page, "Transcripts")).toBeChecked(); await expect(box(page, "Posts")).toBeChecked(); await expect(box(page, "Live chat")).not.toBeChecked(); // Posts moved here from the Type row: one box of that name on the page. await expect(page.getByRole("checkbox", { name: "Posts", exact: true })).toHaveCount(1); await expect(page.getByRole("checkbox", { name: "Videos" })).toBeVisible(); await expect(page.getByRole("checkbox", { name: "Livestreams" })).toBeVisible(); // The bar's hint points at the row, and goes once the box is ticked. const hint = page.getByText(/Live chat available on 2 videos — tick Live chat under Search in/); await expect(hint).toBeVisible(); await box(page, "Live chat").check(); await expect(hint).toHaveCount(0); // The refusal line is always there, and empty unless Search is refused. await expect(page.getByTestId("search-in-empty")).toBeEmpty(); }); test("a plain query reads posts by default; unticking Posts drops them", async ({ page, }) => { await page.goto("/"); await search(page, "kappa"); await expectResultSlugs(page, POST_SLUGS); // A post's section says what it holds, not the leaf's "Transcripts". await expect( page.locator(`[data-result-slug="${POST_SLUGS[0]}"] [data-leaf-section]`), ).toContainText("Posts"); await openFilters(page); await box(page, "Posts").uncheck(); await page.getByTestId("search-submit").click(); await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)"); }); test("one plain leaf reads both corpora into one result set", async ({ page }) => { // A regex leaf of scope "transcripts" — the default leaf — with no posts // leaf beside it: the videos by their cues, the posts by their bodies. const tree = { k: "g", o: "AND", c: [{ k: "l", q: "alpha|kappa", s: "transcripts", r: 1 }], }; await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(tree))}`); await expectResultSlugs(page, [...VIDEO_SLUGS, ...POST_SLUGS]); await expect(page.getByTestId("results-summary")).toHaveText( "Matching videos (5 videos, 5 hits)", ); }); test("ticking Live chat reads the chat: hits appear, badged live chat", async ({ page, }) => { await page.goto("/"); // "message" is only in VIDEO_CHAT_SMALL's live chat: by default, nothing. await search(page, "message"); await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)"); await openFilters(page); await box(page, "Live chat").check(); await page.getByTestId("search-submit").click(); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); await expect(page.getByTestId("results-summary")).toHaveText( "Matching videos (1 video, 30 hits)", ); const card = page.locator(`[data-result-slug="${CHAT_SMALL_SLUG}"]`); // Every hit shown wears the track badge; the section says Live chat. const hits = card.locator("[data-leaf-section] + ul > li"); const badges = card.getByText("live chat", { exact: true }); await expect(badges.first()).toBeVisible(); expect(await badges.count()).toBe(await hits.count()); await expect(card.locator("[data-leaf-section]")).toContainText("Live chat"); // The URL carries the query the visitor built, not the rewrite. const qt = new URL(page.url()).searchParams.get("qt") ?? ""; expect(JSON.parse(qt)).toEqual({ k: "g", o: "AND", c: [{ k: "l", q: "message", s: "transcripts" }], }); }); test("with Transcripts off and Live chat on, a word only in the cues finds nothing", async ({ page, }) => { await page.goto("/"); await openFilters(page); await box(page, "Transcripts").uncheck(); await box(page, "Live chat").check(); // "line" is in every video's cues and nowhere else. await search(page, "line"); await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)"); await expect(page.locator("[data-card-header]")).toHaveCount(0); await search(page, "message"); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); // With Transcripts back on, "line" finds all three again. await box(page, "Transcripts").check(); await search(page, "line"); await expectResultSlugs(page, VIDEO_SLUGS); }); test("with nothing ticked Search is refused and the row says so", async ({ page, }) => { await page.goto("/"); await openFilters(page); await leafInput(page).fill("kappa"); await box(page, "Transcripts").uncheck(); await box(page, "Posts").uncheck(); // Live chat is off by default: nothing is ticked now. const submit = page.getByTestId("search-submit"); const empty = page.getByTestId("search-in-empty"); await expect(submit).toBeDisabled(); await expect(empty).toHaveText("Search in: pick at least one"); await expect(empty).toHaveAttribute("aria-live", "polite"); // The disabled button says why, to a screen reader as well as on hover. await expect(submit).toHaveAccessibleDescription("Search in: pick at least one"); // A save commits the draft, so Save as… is refused the same way. await expect(page.getByRole("button", { name: "Save as…" })).toBeDisabled(); await expect(page.getByText("Press Enter or click Search to apply")).toHaveCount(0); // Enter does not commit it either. await leafInput(page).press("Enter"); await page.waitForTimeout(500); expect(new URL(page.url()).searchParams.get("qt")).toBeNull(); await expect(page.getByTestId("results-summary")).toHaveCount(0); await box(page, "Live chat").check(); await expect(submit).toBeEnabled(); await expect(empty).toBeEmpty(); await expect(submit).toHaveAccessibleDescription(""); await expect(page.getByRole("button", { name: "Save as…" })).toBeEnabled(); await box(page, "Live chat").uncheck(); await expect(submit).toBeDisabled(); await box(page, "Posts").check(); await expect(submit).toBeEnabled(); await submit.click(); await expectResultSlugs(page, POST_SLUGS); }); test("the row survives a reload and a saved profile", async ({ page }) => { await page.goto("/"); await openFilters(page); await box(page, "Transcripts").uncheck(); await box(page, "Live chat").check(); await search(page, "message"); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); await page.reload(); await openFilters(page); await expect(box(page, "Transcripts")).not.toBeChecked(); await expect(box(page, "Posts")).toBeChecked(); await expect(box(page, "Live chat")).toBeChecked(); // Save the row as a profile. page.once("dialog", (d) => d.accept("chat only")); await page.getByRole("button", { name: "Save as…" }).click(); await expect(page.getByTestId("profile-select")).toHaveValue("chat only"); await expect(page.getByTestId("profile-dirty-dot")).toHaveCount(0); // Back to the default row and Search: the commit leaves the profile. await box(page, "Transcripts").check(); await box(page, "Live chat").uncheck(); await page.getByTestId("search-submit").click(); await expect(page.getByTestId("profile-select")).toHaveValue(""); await page.reload(); await openFilters(page); await expect(box(page, "Transcripts")).toBeChecked(); await expect(box(page, "Live chat")).not.toBeChecked(); // Loading the profile brings its row back, and it does not read as changed. await page.getByTestId("profile-select").selectOption("chat only"); await expect(box(page, "Transcripts")).not.toBeChecked(); await expect(box(page, "Posts")).toBeChecked(); await expect(box(page, "Live chat")).toBeChecked(); await expect(page.getByTestId("profile-dirty-dot")).toHaveCount(0); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); // Stored off the default only: `notr` and `lc`, and no `nop`. const stored = await page.evaluate( (key) => JSON.parse(window.localStorage.getItem(key) ?? "null"), STORAGE_KEY, ); const profile = stored.profiles["chat only"]; expect(profile.notr).toBe(true); expect(profile.lc).toBe(true); expect("nop" in profile).toBe(false); }); test("an empty query with Transcripts off still lists every video", async ({ page, }) => { await page.goto("/"); await openFilters(page); await box(page, "Transcripts").uncheck(); await showAll(page); await expect(page.getByTestId("results-summary")).toHaveText("All videos (3)"); await expectResultSlugs(page, VIDEO_SLUGS); }); test("a leaf asked for by name is not changed by the row", async ({ page }) => { // A "Live chat" leaf reads the chat with the row at its default (Live // chat unticked)… const chatLeaf = { k: "g", o: "AND", c: [{ k: "l", q: "message", s: "chat" }] }; await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(chatLeaf))}`); await expectResultSlugs(page, [CHAT_SMALL_SLUG]); await openFilters(page); await expect(box(page, "Live chat")).not.toBeChecked(); // …and a "Posts" leaf reads posts with Transcripts and Posts unticked // (Live chat ticked, so the row reads something and can be committed). await box(page, "Transcripts").uncheck(); await box(page, "Posts").uncheck(); await box(page, "Live chat").check(); await page.getByTestId("search-submit").click(); const postsLeaf = { k: "g", o: "AND", c: [{ k: "l", q: "kappa", s: "posts" }] }; await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(postsLeaf))}`); await openFilters(page); await expect(box(page, "Transcripts")).not.toBeChecked(); await expect(box(page, "Posts")).not.toBeChecked(); await expectResultSlugs(page, POST_SLUGS); }); test("a plain query with a tag chip on finishes, with its results or none", async ({ page, }) => { // Posts carry no curated tags, so under a chip the plain query reads no // posts; before the fix the posts copy it read had an empty scope and the // search never finished. await installTagRoutes(page); await page.goto("/"); await openFilters(page); await page.locator(`[data-testid="tag-chip"][data-tag-id="${TAG_COLLAB}"]`).click(); await search(page, "alpha"); await expectResultSlugs(page, [CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]); await expectFinished(page); // Posts alone under a chip: nothing can match, and it says so. await box(page, "Transcripts").uncheck(); await search(page, "kappa"); await expect(page.getByText("No matching videos.")).toBeVisible({ timeout: 15_000 }); await expectFinished(page); }); test("with no video type kept, a plain query still reads the posts, and finishes", async ({ page, }) => { // The transcripts copy's scope is then empty (the Type row keeps no video). await page.goto("/"); await openFilters(page); await page.getByRole("checkbox", { name: "Videos" }).uncheck(); await page.getByRole("checkbox", { name: "Livestreams" }).uncheck(); await search(page, "kappa"); await expectResultSlugs(page, POST_SLUGS); await expectFinished(page); }); });