import { expect, test, type Page, type Route } from "@playwright/test"; // Self-contained fixtures with one video per VideoState, so the state badges // and the nested Availability filter (common/components/WorkspaceSearchBar.tsx // + SearchSessionContext.passesFilter) each have every leaf to exercise. // // Availability is the first per-card indicator this viewer has ever had: // before the state enum, a deleted or unlisted video looked exactly like an // available one and the only signal was a filter you had to already suspect. // // Searches run in the "Title / channel" (metadata) scope — every title // contains "report", so presence state is the only variable. const CHANNEL = "Test Channel"; const CHANNEL_SLUG = "test-channel"; type State = | "available" | "maybe_missing" | "unlisted" | "private" | "members_only" | "deleted"; type Vid = { id: string; title: string; state: State }; const VIDS: Vid[] = [ { id: "vid-available", title: "Report available", state: "available" }, { id: "vid-maybe", title: "Report unconfirmed", state: "maybe_missing" }, { id: "vid-unlisted", title: "Report unlisted", state: "unlisted" }, { id: "vid-private", title: "Report private", state: "private" }, { id: "vid-members", title: "Report members", state: "members_only" }, { id: "vid-deleted", title: "Report deleted", state: "deleted" }, ]; function summary(v: Vid) { return { slug: `${CHANNEL_SLUG}/${v.id}`, id: v.id, channelSlug: CHANNEL_SLUG, title: v.title, uploadDate: "20260101", date: "2026-01-01", duration: "5:00", channel: CHANNEL, isLivestream: false, ageRestricted: false, // Written the way the build writes them: derived from state, and `state` // itself omitted when available. isDeleted: v.state === "deleted", isUnlisted: v.state === "unlisted", ...(v.state === "available" ? {} : { state: v.state }), platform: "youtube" as const, webpageUrl: `https://example.com/${v.id}`, }; } async function fulfillJson(route: Route, body: unknown) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body), }); } async function installRoutes(page: Page) { const list = VIDS.map(summary); await page.route("**/summaries/manifest.json", async (route) => { await fulfillJson(route, { version: 3, totalCount: list.length, pageSize: 1000, pageCount: 1, generatedAt: new Date().toISOString(), channels: [{ name: CHANNEL, count: list.length, groupId: "default" }], groups: [{ id: "default", name: "All channels", selectedByDefault: true }], defaultGroupId: "default", }); }); await page.route("**/summaries/page-*.json", async (route) => { await fulfillJson(route, list); }); await page.route(/\/transcripts\/[^/]+\/manifest\.json$/, async (route) => { const slugToPage: Record = {}; for (const v of VIDS) slugToPage[v.id] = 0; await fulfillJson(route, { version: 1, channelSlug: CHANNEL_SLUG, pageCount: 1, maxPageBytes: 8388608, generatedAt: new Date().toISOString(), slugToPage, }); }); await page.route(/\/transcripts\/[^/]+\/page-\d+\.json$/, async (route) => { await fulfillJson( route, list.map((s) => ({ slug: s.slug, id: s.id, cues: [] })), ); }); await page.route("**/subs/manifest.json", async (route) => { await fulfillJson(route, { version: 4, channels: [], totalCount: 0, liveChatTotalCount: 0, generatedAt: new Date().toISOString(), }); }); } function leafInput(page: Page) { return page.locator('[data-testid^="leaf-query-"]').first(); } function scopeSelect(page: Page) { return page.locator('[data-testid^="leaf-scope-"]').first(); } async function waitForHydration(page: Page) { await page.getByTestId("query-builder").waitFor(); } async function searchReports(page: Page) { await scopeSelect(page).selectOption({ label: "Title / channel" }); const input = leafInput(page); await input.click(); await input.fill("report"); await input.press("Enter"); await page.waitForURL(/[?&]qt=/); } // The result-card header button carries " <channel>". function card(page: Page, titleRe: RegExp) { return page.getByRole("button", { name: new RegExp(`${titleRe.source}.*Test Channel`), }); } // Toggle a filter checkbox and commit. The checkboxes are Radix roots, so // click the control itself rather than its label text. async function setFilter(page: Page, testId: string, checked: boolean) { const box = page.getByTestId(testId); const state = await box.getAttribute("data-state"); const isChecked = state === "checked"; if (isChecked !== checked) await box.click(); await page.getByTestId("search-submit").click(); } test.describe("export search — availability state badges", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); await page.goto("/"); await waitForHydration(page); }); test("each missing state renders its badge; available renders none", async ({ page, }) => { await searchReports(page); for (const v of VIDS) await expect(card(page, new RegExp(v.title))).toBeVisible(); await expect(page.getByTestId("state-badge-maybe_missing")).toHaveText( /Missing\?/i, ); await expect(page.getByTestId("state-badge-deleted")).toHaveText(/Deleted/i); await expect(page.getByTestId("state-badge-private")).toHaveText(/Private/i); await expect(page.getByTestId("state-badge-members_only")).toHaveText( /Members/i, ); await expect(page.getByTestId("state-badge-unlisted")).toHaveText( /Unlisted/i, ); // Six videos, five badges — the available one carries nothing. await expect(page.locator('[data-testid^="state-badge-"]')).toHaveCount(5); }); test("the unconfirmed badge reads as a suspicion, not a finding", async ({ page, }) => { await searchReports(page); const badge = page.getByTestId("state-badge-maybe_missing"); // The "?" is the whole point: we know it left the listing, not why. await expect(badge).toHaveText(/\?$/); await expect(badge).toHaveAttribute("title", /not yet confirmed/i); }); }); test.describe("export search — nested availability filter", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); await page.goto("/"); await waitForHydration(page); }); test("unchecking Missing drops all five leaves, keeping only available", async ({ page, }) => { await searchReports(page); await setFilter(page, "av-missing", false); await expect(card(page, /Report available/)).toBeVisible(); for (const v of VIDS.filter((x) => x.state !== "available")) { await expect(card(page, new RegExp(v.title))).toHaveCount(0); } }); test("unchecking Available keeps every missing video", async ({ page }) => { await searchReports(page); await setFilter(page, "av-available", false); await expect(card(page, /Report available/)).toHaveCount(0); for (const v of VIDS.filter((x) => x.state !== "available")) { await expect(card(page, new RegExp(v.title))).toBeVisible(); } }); test("a single leaf filters to exactly its own state", async ({ page }) => { await searchReports(page); // Drop the parent, then re-check one leaf. await setFilter(page, "av-missing", false); await setFilter(page, "av-available", false); await setFilter(page, "av-private", true); await expect(card(page, /Report private/)).toBeVisible(); await expect(card(page, /Report available/)).toHaveCount(0); await expect(card(page, /Report deleted/)).toHaveCount(0); await expect(card(page, /Report unconfirmed/)).toHaveCount(0); }); test("the Missing parent goes indeterminate when only some leaves are kept", async ({ page, }) => { await searchReports(page); const parent = page.getByTestId("av-missing"); await expect(parent).toHaveAttribute("data-state", "checked"); // One leaf off → some but not all. await page.getByTestId("av-deleted").click(); await expect(parent).toHaveAttribute("data-state", "indeterminate"); // All leaves off → unchecked. for (const s of ["maybe_missing", "private", "members_only", "unlisted"]) { await page.getByTestId(`av-${s}`).click(); } await expect(parent).toHaveAttribute("data-state", "unchecked"); // Toggling the parent back on restores every leaf. await parent.click(); await expect(parent).toHaveAttribute("data-state", "checked"); for (const s of [ "maybe_missing", "deleted", "private", "members_only", "unlisted", ]) { await expect(page.getByTestId(`av-${s}`)).toHaveAttribute( "data-state", "checked", ); } }); test("the state filter persists across a reload", async ({ page }) => { await searchReports(page); await setFilter(page, "av-missing", false); await expect(card(page, /Report deleted/)).toHaveCount(0); await page.reload(); await waitForHydration(page); await expect(page.getByTestId("av-missing")).toHaveAttribute( "data-state", "unchecked", ); await expect(card(page, /Report available/)).toBeVisible(); await expect(card(page, /Report deleted/)).toHaveCount(0); }); }); test.describe("export search — availability in shared links", () => { test.use({ permissions: ["clipboard-read", "clipboard-write"] }); test.beforeEach(async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await installRoutes(page); await page.goto("/"); await waitForHydration(page); }); test("a share link carries per-state tokens under fv=2 and round-trips", async ({ page, }) => { await searchReports(page); await setFilter(page, "av-missing", false); await page .getByTestId("profiles-row") .getByRole("button", { name: "Share current search" }) .click(); await expect( page.getByRole("button", { name: "Link copied!" }), ).toBeVisible(); const clip = await page.evaluate(() => navigator.clipboard.readText()); const url = new URL(clip); expect(url.searchParams.get("fv")).toBe("2"); expect(url.searchParams.getAll("fav")).toEqual(["a"]); await page.goto(`${url.pathname}${url.search}`); await waitForHydration(page); await expect(card(page, /Report available/)).toBeVisible(); await expect(card(page, /Report deleted/)).toHaveCount(0); }); test("a legacy fv=1 link still yields the missing videos", async ({ page, }) => { // fav=a&fav=u&fav=d is what an old "everything" link looks like; under the // new taxonomy its author meant all six states, so nothing may drop out. await page.goto("/?fv=1&fav=a&fav=u&fav=d&fc=Test+Channel&ft=v&fa=a"); await waitForHydration(page); await searchReports(page); for (const v of VIDS) { await expect(card(page, new RegExp(v.title))).toBeVisible(); } }); });