import { test, expect, type Page } from "@playwright/test"; import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; import { PROJECT_URL } from "../../common/lib/project"; import { readJson, writeSettings } from "./helpers"; const EXPORT_BASE = `http://localhost:${portFor("EXPORT_PORT")}`; // NOTE: the search UI is the layered query builder (common/components/ // TranscriptSearch.tsx + QueryLeafView.tsx). A search commit writes the query // tree to `?qt=` (legacy `q`/`re`/`m` are cleared); filters/regex persist to // localStorage, NOT the URL. Each leaf has a scope (Transcripts / Live chat / // Title / channel), so a transcripts-scope search matches transcripts only. type Summary = { slug: string; id: string; channelSlug: string; title: string; uploadDate: string; date: string; duration: string; channel: string; isLivestream: boolean; ageRestricted: boolean; isDeleted: boolean; isUnlisted: boolean; platform: "youtube" | "rumble" | "odysee"; webpageUrl: string; }; const CHANNEL = "Test Channel"; const CHANNEL_SLUG = "test-channel"; const summaries: Summary[] = [ // Title contains "platypus", transcript contains "platypus" makeSummary("v-both", "Platypus facts and figures", false), // Title contains "platypus", transcript does NOT makeSummary("v-title-only", "All about the platypus", false), // Title does not contain "platypus", transcript does makeSummary("v-cue-only", "An ordinary mammal", false), // Deleted, title contains "platypus" makeSummary("v-deleted-title", "Deleted platypus chronicles", true), // Deleted, title does NOT contain "platypus", transcript contains it makeSummary("v-deleted-cue", "Vanished video on aquatic life", true), // Unlisted, title does NOT contain "platypus", transcript contains it makeSummary("v-unlisted-cue", "Hidden mammal documentary", false, true), ]; function makeSummary( id: string, title: string, isDeleted: boolean, isUnlisted = false, ): Summary { return { slug: `${CHANNEL_SLUG}/${id}`, id, channelSlug: CHANNEL_SLUG, title, uploadDate: "20260101", date: "2026-01-01", duration: "5:00", channel: CHANNEL, isLivestream: false, ageRestricted: false, isDeleted, isUnlisted, platform: "youtube", webpageUrl: `https://example.com/${id}`, }; } const transcripts: Record = { "v-both": { cues: [{ start: 12, text: "the platypus has a beak" }] }, "v-title-only": { cues: [{ start: 0, text: "nothing matching here at all" }], }, "v-cue-only": { cues: [{ start: 30, text: "an ordinary platypus appears" }], }, "v-deleted-title": { cues: [{ start: 5, text: "no relevant content in this transcript" }], }, "v-deleted-cue": { cues: [{ start: 45, text: "the platypus swims silently" }], }, "v-unlisted-cue": { cues: [{ start: 60, text: "an unlisted platypus appears" }], }, }; async function installFixtureRoutes(page: Page) { const manifest = { version: 3, totalCount: summaries.length, pageSize: 1000, pageCount: 1, generatedAt: new Date().toISOString(), channels: [{ name: CHANNEL, count: summaries.length, groupId: "default" }], groups: [{ id: "default", name: "All channels", selectedByDefault: true }], defaultGroupId: "default", }; await page.route("**/summaries/manifest.json", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(manifest), }); }); await page.route("**/summaries/page-0000.json", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(summaries), }); }); await page.route("**/transcripts/**/manifest.json", async (route) => { const url = new URL(route.request().url()); const channelSlug = url.pathname.split("/").slice(-2, -1)[0]; if (channelSlug !== CHANNEL_SLUG) { await route.fulfill({ status: 404, body: "" }); return; } const slugToPage: Record = {}; for (const s of summaries) slugToPage[s.id] = 0; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ version: 1, pageSize: 1000, slugToPage }), }); }); await page.route("**/transcripts/**/page-*.json", async (route) => { const body = summaries.map((s) => ({ slug: s.slug, id: s.id, cues: transcripts[s.id].cues, })); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body), }); }); // Subs manifest with no live chat by default. await page.route("**/subs/manifest.json", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ version: 4, channels: [], totalCount: 0, liveChatTotalCount: 0, generatedAt: new Date().toISOString(), }), }); }); } // Routes for a populated subs manifest with a single live-chat video. Call // AFTER installFixtureRoutes (overrides the empty subs-manifest route). async function installLiveChatRoutes(page: Page) { const subsManifest = { version: 4, channels: [ { name: CHANNEL, slug: CHANNEL_SLUG, videoCount: 1, tracks: ["live_chat", "en"], liveChatCount: 1, groupId: "default", }, ], totalCount: 2, liveChatTotalCount: 1, generatedAt: new Date().toISOString(), }; await page.unroute("**/subs/manifest.json"); await page.route("**/subs/manifest.json", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(subsManifest), }); }); // Per-channel subs manifest. Regex so we don't also match /subs/manifest.json. await page.route(/\/subs\/[^/]+\/manifest\.json$/, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ version: 1, channelSlug: CHANNEL_SLUG, pageCount: 1, maxPageBytes: 8388608, generatedAt: new Date().toISOString(), tracks: ["live_chat", "en"], slugToPage: { "v-both": 0 }, }), }); }); await page.route(/\/subs\/[^/]+\/page-\d+\.json$/, async (route) => { // Two tracks, both containing "platypus" — only live_chat should match. const detail = { slug: `${CHANNEL_SLUG}/v-both`, id: "v-both", channelSlug: CHANNEL_SLUG, title: "Platypus facts and figures", uploadDate: "20260101", date: "2026-01-01", duration: "5:00", channel: CHANNEL, isLivestream: true, ageRestricted: false, isDeleted: false, platform: "youtube" as const, webpageUrl: "https://example.com/v-both", tracks: { live_chat: [{ start: 11, text: "viewer: platypus in chat!" }], en: [{ start: 22, text: "captioned platypus mention" }], }, }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([detail]), }); }); } // The single (compact) query leaf input. Stable across scope changes (the // placeholder text varies by scope, so target the testid prefix). 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 search(page: Page, query: string) { const input = leafInput(page); await input.click(); await input.fill(query); await expect(input).toHaveValue(query); await input.press("Enter"); // commitSearch writes ?qt=; wait for it so subsequent assertions run // against the post-search render. await page.waitForURL(/[?&]qt=/); } async function searchInScope( page: Page, scopeLabel: "Transcripts" | "Live chat" | "Title / channel", query: string, ) { await scopeSelect(page).selectOption({ label: scopeLabel }); await search(page, query); } async function waitForHydration(page: Page) { // The query builder mounts only after the TranscriptSearch client component // hydrates, so its container is a reliable readiness probe. await page.getByTestId("query-builder").waitFor(); } // A result card header (a button) for a given video title. function resultCard(page: Page, titleRe: RegExp) { return page.getByRole("button", { name: titleRe }); } test.describe("export TranscriptSearch — query builder + filter layout", () => { test.beforeEach(async ({ page }) => { await installFixtureRoutes(page); await page.goto(EXPORT_BASE); await waitForHydration(page); }); test("live chat hint is hidden when the subs manifest reports no live chat", async ({ page, }) => { await expect(page.getByText(/Live chat available on/)).toHaveCount(0); }); test("live chat hint stays hidden when manifest has only alt-language tracks", async ({ page, }) => { await page.unroute("**/subs/manifest.json"); await page.route("**/subs/manifest.json", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ version: 4, channels: [ { name: CHANNEL, slug: CHANNEL_SLUG, videoCount: 1, tracks: ["en"], liveChatCount: 0, groupId: "default", }, ], totalCount: 1, liveChatTotalCount: 0, generatedAt: new Date().toISOString(), }), }); }); await page.reload(); await waitForHydration(page); await expect(page.getByText(/Live chat available on/)).toHaveCount(0); }); test("live chat hint appears when the subs manifest reports liveChatTotalCount > 0", async ({ page, }) => { await installLiveChatRoutes(page); await page.reload(); await waitForHydration(page); await expect( page.getByText(/Live chat available on 1 video/), ).toBeVisible(); }); test("filter checkboxes are grouped under Type / Audience / Availability labels", async ({ page, }) => { await search(page, "platypus"); await expect(page.getByText("Type", { exact: true })).toBeVisible(); await expect(page.getByText("Audience", { exact: true })).toBeVisible(); await expect( page.getByText("Availability", { exact: true }), ).toBeVisible(); await expect(page.getByRole("checkbox", { name: "Videos", exact: true })).toBeChecked(); await expect( page.getByRole("checkbox", { name: "Available", exact: true }), ).toBeChecked(); await expect( page.getByRole("checkbox", { name: "Unlisted", exact: true }), ).toBeChecked(); await expect( page.getByRole("checkbox", { name: "Deleted", exact: true }), ).toBeChecked(); }); test("a metadata-scope search surfaces videos by title", async ({ page }) => { // Title / channel scope matches the title, so the title-only video (no // transcript match) surfaces here where a transcripts search wouldn't. await searchInScope(page, "Title / channel", "platypus"); await expect( resultCard(page, /Platypus facts and figures.*Test Channel/), ).toBeVisible(); await expect( resultCard(page, /All about the platypus.*Test Channel/), ).toBeVisible(); }); test("'Deleted' unchecked hides deleted videos from results", async ({ page, }) => { await search(page, "platypus"); // The deleted video with a transcript match is shown by default. await expect( resultCard(page, /Vanished video on aquatic life.*Test Channel/), ).toBeVisible(); await page.getByRole("checkbox", { name: "Deleted", exact: true }).uncheck(); await page.getByTestId("search-submit").click(); await expect( resultCard(page, /Vanished video on aquatic life/), ).toHaveCount(0); // Non-deleted matches remain. await expect( resultCard(page, /Platypus facts and figures.*Test Channel/), ).toBeVisible(); }); test("'Available' unchecked narrows to non-available videos", async ({ page, }) => { await search(page, "platypus"); await page.getByRole("checkbox", { name: "Available", exact: true }).uncheck(); await page.getByTestId("search-submit").click(); // Deleted + unlisted matches remain (separate buckets). await expect( resultCard(page, /Vanished video on aquatic life.*Test Channel/), ).toBeVisible(); await expect( resultCard(page, /Hidden mammal documentary.*Test Channel/), ).toBeVisible(); // Public videos drop out. await expect( resultCard(page, /Platypus facts and figures/), ).toHaveCount(0); await expect(resultCard(page, /An ordinary mammal/)).toHaveCount(0); }); test("unlisted videos are shown by default and 'Unlisted' unchecked hides them", async ({ page, }) => { // Give the window-virtualized results enough height to materialize all four // matched rows (the default 720px viewport only fits three Archive-density // cards, leaving the last one un-rendered and unscrollable). await page.setViewportSize({ width: 1280, height: 1600 }); await search(page, "platypus"); await expect( resultCard(page, /Hidden mammal documentary.*Test Channel/), ).toBeVisible(); await page.getByRole("checkbox", { name: "Unlisted", exact: true }).uncheck(); await page.getByTestId("search-submit").click(); await expect( resultCard(page, /Hidden mammal documentary/), ).toHaveCount(0); // Public matches are unaffected. await expect( resultCard(page, /Platypus facts and figures.*Test Channel/), ).toBeVisible(); }); }); test.describe("export TranscriptSearch — live chat scope", () => { test.beforeEach(async ({ page }) => { await installFixtureRoutes(page); await installLiveChatRoutes(page); await page.goto(EXPORT_BASE); await waitForHydration(page); }); test("only live_chat track results are surfaced in live chat scope", async ({ page, }) => { await searchInScope(page, "Live chat", "platypus"); // The live_chat cue surfaces; the captioned-English cue must not, even // though it also contains "platypus". await expect( page.getByText("viewer: platypus in chat!"), ).toBeVisible(); await expect( page.getByText("captioned platypus mention"), ).toHaveCount(0); }); }); test.describe("export TranscriptSearch — advanced options persistence", () => { test.beforeEach(async ({ page }) => { await installFixtureRoutes(page); await page.goto(EXPORT_BASE); await waitForHydration(page); }); test("Max hits per batch persists across reload", async ({ page }) => { await page.locator("summary").filter({ hasText: "Advanced options" }).click(); const maxHits = page.getByRole("spinbutton", { name: "Max hits per batch", }); await maxHits.fill("250"); await maxHits.press("Tab"); await page.reload(); await waitForHydration(page); await page.locator("summary").filter({ hasText: "Advanced options" }).click(); await expect( page.getByRole("spinbutton", { name: "Max hits per batch" }), ).toHaveValue("250"); }); test("Reset to defaults restores advanced values and persists the reset", async ({ page, }) => { await page.locator("summary").filter({ hasText: "Advanced options" }).click(); const maxHits = page.getByRole("spinbutton", { name: "Max hits per batch", }); await maxHits.fill("250"); await maxHits.press("Tab"); await page.getByRole("button", { name: "Reset to defaults" }).click(); await expect(maxHits).toHaveValue("500"); await page.reload(); await waitForHydration(page); await page.locator("summary").filter({ hasText: "Advanced options" }).click(); await expect( page.getByRole("spinbutton", { name: "Max hits per batch" }), ).toHaveValue("500"); }); test("Advanced reset does not touch filter checkboxes", async ({ page }) => { await search(page, "platypus"); await page.getByRole("checkbox", { name: "Deleted", exact: true }).uncheck(); await page.locator("summary").filter({ hasText: "Advanced options" }).click(); const maxHits = page.getByRole("spinbutton", { name: "Max hits per batch", }); await maxHits.fill("250"); await maxHits.press("Tab"); await page.getByRole("button", { name: "Reset to defaults" }).click(); await expect(maxHits).toHaveValue("500"); await expect( page.getByRole("checkbox", { name: "Deleted", exact: true }), ).not.toBeChecked(); }); }); test.describe("export TranscriptSearch — filters collapse persistence", () => { test.beforeEach(async ({ page }) => { await installFixtureRoutes(page); await page.goto(EXPORT_BASE); await waitForHydration(page); }); test("filters section is open by default", async ({ page }) => { await expect(page.getByRole("checkbox", { name: "Videos", exact: true })).toBeVisible(); const summary = page.locator("summary").filter({ hasText: "Filters" }); await expect(summary).toBeVisible(); await expect(summary.locator("xpath=..")).toHaveJSProperty("open", true); }); test("clicking summary collapses and reveals compact channel list", async ({ page, }) => { const summary = page.locator("summary").filter({ hasText: "Filters" }); await summary.click(); await expect(page.getByRole("checkbox", { name: "Videos", exact: true })).toBeHidden(); await expect(summary).toContainText(/\d+ of \d+ channel/); const compactList = page.getByTestId("filters-summary-channels"); await expect(compactList).toBeVisible(); await expect(compactList).toContainText(CHANNEL); }); test("collapse state persists across reload", async ({ page }) => { await page.locator("summary").filter({ hasText: "Filters" }).click(); await expect(page.getByRole("checkbox", { name: "Videos", exact: true })).toBeHidden(); await page.reload(); await waitForHydration(page); await expect(page.getByRole("checkbox", { name: "Videos", exact: true })).toBeHidden(); await page.locator("summary").filter({ hasText: "Filters" }).click(); await expect(page.getByRole("checkbox", { name: "Videos", exact: true })).toBeVisible(); await page.reload(); await waitForHydration(page); await expect(page.getByRole("checkbox", { name: "Videos", exact: true })).toBeVisible(); }); }); test.describe("export footer", () => { test.beforeEach(async ({ page }) => { await installFixtureRoutes(page); await page.goto(EXPORT_BASE); await waitForHydration(page); }); test("footer credits the project + carries social links from the site", async ({ page, }) => { const footer = page.locator("footer"); await expect(footer).toBeVisible(); // Every deployed archive links back to the project that built it. This // replaced a per-site `code.tar.gz` link — each archive used to ship its // own copy of the source, which is now published once, centrally. // // PROJECT_URL is imported rather than typed out so the assertion cannot // drift from the constant that is baked into every instance. const builtWith = footer.getByRole("link", { name: "Archilyzer" }); await expect(builtWith).toBeVisible(); await expect(builtWith).toHaveAttribute("href", PROJECT_URL); // Navigation, not a social link: same tab, as the header's Archilyzer link. await expect(builtWith).not.toHaveAttribute("target", "_blank"); // The accessible name is exactly the product, with "Built with" outside it. await expect(footer).toContainText("Built with Archilyzer"); // The fixture site (editor/e2e/fixtures/sites/testsite) ships one GitHub // social link. const social = footer.getByRole("link", { name: "GitHub" }); await expect(social).toHaveAttribute("title", "GitHub"); await expect(social).toHaveAttribute( "href", "https://github.com/example/repo", ); await expect(social).toHaveAttribute("target", "_blank"); const svgHtml = await social.innerHTML(); expect(svgHtml).toContain("]*\swidth=/); expect(svgHtml).toMatch(/]*\sheight=/); }); test("a site's own social links win over the global default", async ({ page, }) => { // The export server shares the editor's test-settings.json. Set a global // default, then confirm the testsite (which defines its own links) // overrides it instead of showing the global one. Restore afterwards so we // don't leak settings into later tests. const original = await readJson>( "test-settings.json", ); try { await writeSettings({ ...original, socialLinks: [ { label: "GlobalOnly", url: "https://global.example", svg: '', }, ], }); await page.reload(); await waitForHydration(page); const footer = page.locator("footer"); await expect(footer.getByRole("link", { name: "GitHub" })).toBeVisible(); await expect( footer.getByRole("link", { name: "GlobalOnly" }), ).toHaveCount(0); } finally { await writeSettings(original); } }); }); test.describe("export TranscriptSearch — deferred search submit", () => { test.beforeEach(async ({ page }) => { await installFixtureRoutes(page); await page.goto(EXPORT_BASE); await waitForHydration(page); }); test("toggling a filter does not auto-commit; results stay until Search is clicked", async ({ page, }) => { await search(page, "platypus"); const deletedVideo = resultCard( page, /Vanished video on aquatic life.*Test Channel/, ); await expect(deletedVideo).toBeVisible(); // Toggle "Deleted" off — the result is STILL rendered (deferred commit). await page.getByRole("checkbox", { name: "Deleted", exact: true }).uncheck(); await expect(deletedVideo).toBeVisible(); // Dirty hint appears. await expect( page.getByText("Press Enter or click Search to apply"), ).toBeVisible(); // Clicking Search commits the filter change. await page.getByTestId("search-submit").click(); await expect(deletedVideo).toHaveCount(0); await expect( page.getByText("Press Enter or click Search to apply"), ).toHaveCount(0); }); test("Search button is the primary CTA when filters are dirty and muted otherwise", async ({ page, }) => { await search(page, "platypus"); const searchBtn = page.getByTestId("search-submit"); await expect(searchBtn).toBeVisible(); // Pristine after commit: muted/outline variant (data-dirty="false"). The // dirty signal is a stable data attribute, not a color class, since the kit // Button drives the primary style. await expect(searchBtn).toHaveAttribute("data-dirty", "false"); await page.getByRole("checkbox", { name: "Deleted", exact: true }).uncheck(); // Dirty: primary CTA. await expect(searchBtn).toHaveAttribute("data-dirty", "true"); // Submit, returns to pristine. await searchBtn.click(); await expect(searchBtn).toHaveAttribute("data-dirty", "false"); }); });