import { expect, test, type Page, type Route } from "@playwright/test"; // Self-contained fixtures with videos spread across distinct upload dates so // the search date-range filter (common/components/TranscriptSearch.tsx) has // something to slice. We search in the "Title / channel" (metadata) scope — // every title contains "report", so the date range is the only variable. const CHANNEL = "Test Channel"; const CHANNEL_SLUG = "test-channel"; type Vid = { id: string; title: string; uploadDate: string }; const VIDS: Vid[] = [ { id: "vid-2024", title: "Report from 2024", uploadDate: "20240101" }, { id: "vid-2025", title: "Report from 2025", uploadDate: "20250601" }, { id: "vid-2026", title: "Report from 2026", uploadDate: "20260301" }, ]; function summary(v: Vid) { return { slug: `${CHANNEL_SLUG}/${v.id}`, id: v.id, channelSlug: CHANNEL_SLUG, title: v.title, uploadDate: v.uploadDate, date: `${v.uploadDate.slice(0, 4)}-${v.uploadDate.slice(4, 6)}-${v.uploadDate.slice(6, 8)}`, duration: "5:00", channel: CHANNEL, isLivestream: false, ageRestricted: false, isDeleted: false, isUnlisted: false, 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); }); // Metadata-scope search reads titles from the summaries; transcripts are // never fetched, but stub the routes so nothing falls through to the dev // server. 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: [] })), ); }); // No live chat. 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(); } // Commit a metadata-scope query that matches every fixture title. 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=/); } // Set the From/To date inputs (YYYY-MM-DD; "" clears) and commit the filters. async function setDates(page: Page, from: string, to: string) { await page.getByTestId("date-from").fill(from); await page.getByTestId("date-to").fill(to); await page.getByTestId("search-submit").click(); } // The result-card header button carries " <channel>"; the channel // suffix disambiguates it from the metadata-match snippet button, which can // also contain the title text. function card(page: Page, titleRe: RegExp) { return page.getByRole("button", { name: new RegExp(`${titleRe.source}.*Test Channel`), }); } test.describe("export search — date-range filter", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); await page.goto("/"); await waitForHydration(page); }); test("range narrows results to the window with inclusive bounds", async ({ page, }) => { await searchReports(page); // All three visible before any date filter. await expect(card(page, /Report from 2024/)).toBeVisible(); await expect(card(page, /Report from 2025/)).toBeVisible(); await expect(card(page, /Report from 2026/)).toBeVisible(); // From is exactly on the 2025 video's date → inclusive, it stays. await setDates(page, "2025-06-01", "2025-12-31"); await expect(card(page, /Report from 2025/)).toBeVisible(); await expect(card(page, /Report from 2024/)).toHaveCount(0); await expect(card(page, /Report from 2026/)).toHaveCount(0); }); test("To-only bound is inclusive", async ({ page }) => { await searchReports(page); // To is exactly on the 2024 video's date → it stays; later videos drop. await setDates(page, "", "2024-01-01"); await expect(card(page, /Report from 2024/)).toBeVisible(); await expect(card(page, /Report from 2025/)).toHaveCount(0); await expect(card(page, /Report from 2026/)).toHaveCount(0); }); test("From-only keeps that date and everything later", async ({ page }) => { await searchReports(page); await setDates(page, "2025-06-01", ""); await expect(card(page, /Report from 2025/)).toBeVisible(); await expect(card(page, /Report from 2026/)).toBeVisible(); await expect(card(page, /Report from 2024/)).toHaveCount(0); }); test("clearing both bounds restores all matches", async ({ page }) => { await searchReports(page); await setDates(page, "2025-06-01", "2025-12-31"); await expect(card(page, /Report from 2024/)).toHaveCount(0); await setDates(page, "", ""); await expect(card(page, /Report from 2024/)).toBeVisible(); await expect(card(page, /Report from 2025/)).toBeVisible(); await expect(card(page, /Report from 2026/)).toBeVisible(); }); test("range persists across a reload (working snapshot)", async ({ page }) => { await searchReports(page); await setDates(page, "2025-06-01", "2025-12-31"); await expect(card(page, /Report from 2025/)).toBeVisible(); await page.reload(); await waitForHydration(page); await expect(page.getByTestId("date-from")).toHaveValue("2025-06-01"); await expect(page.getByTestId("date-to")).toHaveValue("2025-12-31"); await expect(card(page, /Report from 2025/)).toBeVisible(); await expect(card(page, /Report from 2024/)).toHaveCount(0); await expect(card(page, /Report from 2026/)).toHaveCount(0); }); }); test.describe("export search — date range 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("share link carries the date range and round-trips", async ({ page }) => { await searchReports(page); await setDates(page, "2025-06-01", "2025-12-31"); 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("fdf")).toBe("20250601"); expect(url.searchParams.get("fdt")).toBe("20251231"); // Loading the shared link reproduces the range and the filtered results. await page.goto(`${url.pathname}${url.search}`); await waitForHydration(page); await expect(page.getByTestId("date-from")).toHaveValue("2025-06-01"); await expect(page.getByTestId("date-to")).toHaveValue("2025-12-31"); await expect(card(page, /Report from 2025/)).toBeVisible(); await expect(card(page, /Report from 2024/)).toHaveCount(0); await expect(card(page, /Report from 2026/)).toHaveCount(0); }); test("the date range and chart view both ride along in a shared link", async ({ page, context, }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await searchReports(page); await setDates(page, "2025-06-01", "2025-12-31"); // Switch to the chart view — the chart now visualizes this same search, so // its date scope is just the committed filter (stats aren't mocked in this // spec, so the chart itself shows a load notice; we assert the shared state). await page .getByTestId("view-toggle") .getByRole("button", { name: "Chart" }) .click(); await page .getByTestId("profiles-row") .getByRole("button", { name: "Share current search" }) .click(); const clip = await page.evaluate(() => navigator.clipboard.readText()); const url = new URL(clip); expect(url.searchParams.get("fdf")).toBe("20250601"); expect(url.searchParams.get("fdt")).toBe("20251231"); expect(url.searchParams.get("view")).toBe("chart"); expect(url.searchParams.get("cs")).toBeTruthy(); }); });