Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 2cbba25df88d5d3e0e67883bfe54d44a84d25df0
parent 4d61c8d58ad75f52f6898a0eacc644587f4697cf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 30 May 2026 02:55:27 -0400

fix export-search tests

Diffstat:
Meditor/e2e/export-search.spec.ts | 311++++++++++++++++++++++++++++++-------------------------------------------------
Aeditor/e2e/fixtures/sites/testsite/site.json | 17+++++++++++++++++
Meditor/playwright.config.ts | 10++++++++++
3 files changed, 143 insertions(+), 195 deletions(-)

diff --git a/editor/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts @@ -2,6 +2,12 @@ import { test, expect, type Page } from "@playwright/test"; const EXPORT_BASE = `http://localhost:${process.env.EXPORT_PORT ?? 3010}`; +// 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; @@ -33,8 +39,7 @@ const summaries: Summary[] = [ 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" (keeps the title-badge count - // stable), transcript contains it so it surfaces as a transcript match. + // Unlisted, title does NOT contain "platypus", transcript contains it makeSummary("v-unlisted-cue", "Hidden mammal documentary", false, true), ]; @@ -84,12 +89,14 @@ const transcripts: Record<string, { cues: { start: number; text: string }[] }> = async function installFixtureRoutes(page: Page) { const manifest = { - version: 1, + version: 3, totalCount: summaries.length, pageSize: 1000, pageCount: 1, generatedAt: new Date().toISOString(), - channels: [{ name: CHANNEL, count: summaries.length }], + 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) => { @@ -125,8 +132,6 @@ async function installFixtureRoutes(page: Page) { }); await page.route("**/transcripts/**/page-*.json", async (route) => { - // The on-disk shape is an array of TranscriptDetail entries, each with - // its own `slug`. transcriptCache iterates and matches by slug. const body = summaries.map((s) => ({ slug: s.slug, id: s.id, @@ -139,27 +144,27 @@ async function installFixtureRoutes(page: Page) { }); }); - // Subs manifest empty so subs mode never tries to fetch anything. + // 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: 1, + version: 4, channels: [], totalCount: 0, + liveChatTotalCount: 0, generatedAt: new Date().toISOString(), }), }); }); } -// Routes for a populated subs manifest with a single live-chat video. Used -// by the live-chat tab tests. Call AFTER installFixtureRoutes (overrides -// the empty subs-manifest route). +// 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: 2, + version: 4, channels: [ { name: CHANNEL, @@ -167,6 +172,7 @@ async function installLiveChatRoutes(page: Page) { videoCount: 1, tracks: ["live_chat", "en"], liveChatCount: 1, + groupId: "default", }, ], totalCount: 2, @@ -181,9 +187,7 @@ async function installLiveChatRoutes(page: Page) { body: JSON.stringify(subsManifest), }); }); - // Per-channel subs manifest. Use a regex so we don't accidentally also - // match `/subs/manifest.json` (playwright globs let `**` collapse to zero - // segments). + // 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, @@ -195,8 +199,6 @@ async function installLiveChatRoutes(page: Page) { maxPageBytes: 8388608, generatedAt: new Date().toISOString(), tracks: ["live_chat", "en"], - // The video has both tracks; we'll use it to assert the export only - // surfaces live_chat hits. slugToPage: { "v-both": 0 }, }), }); @@ -230,45 +232,61 @@ async function installLiveChatRoutes(page: Page) { }); } +// 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 = page.getByPlaceholder("Search transcripts..."); + const input = leafInput(page); await input.click(); await input.fill(query); await expect(input).toHaveValue(query); await input.press("Enter"); - // commitSearch writes ?q=… to the URL; wait for it so subsequent assertions - // run against the post-search render rather than the no-query placeholder. - await page.waitForURL(/[?&]q=/); + // commitSearch writes ?qt=<tree>; 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 "Regex" checkbox is only rendered after React hydrates the - // TranscriptSearch client component, so it's a reliable hydration probe. - await page.getByRole("checkbox", { name: "Regex" }).waitFor(); + // 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 — title matching + filter layout", () => { +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 tab is hidden when the subs manifest reports no live chat", async ({ + test("live chat hint is hidden when the subs manifest reports no live chat", async ({ page, }) => { - // The search-mode tablist is not rendered at all when there's no live - // chat content to switch to. (Next.js dev-tools may render unrelated - // tablists; scope the search by aria-label.) - await expect( - page.getByRole("tablist", { name: "Search mode" }), - ).toHaveCount(0); - await expect(page.getByRole("tab", { name: /Live chat/ })).toHaveCount(0); - // Also assert the old "Subs" label is gone. - await expect(page.getByRole("tab", { name: /^Subs/ })).toHaveCount(0); + await expect(page.getByText(/Live chat available on/)).toHaveCount(0); }); - test("live chat tab is hidden even when manifest has only alt-language tracks", async ({ + test("live chat hint stays hidden when manifest has only alt-language tracks", async ({ page, }) => { await page.unroute("**/subs/manifest.json"); @@ -277,7 +295,7 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = status: 200, contentType: "application/json", body: JSON.stringify({ - version: 2, + version: 4, channels: [ { name: CHANNEL, @@ -285,6 +303,7 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = videoCount: 1, tracks: ["en"], liveChatCount: 0, + groupId: "default", }, ], totalCount: 1, @@ -296,10 +315,10 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = await page.reload(); await waitForHydration(page); - await expect(page.getByRole("tab", { name: /Live chat/ })).toHaveCount(0); + await expect(page.getByText(/Live chat available on/)).toHaveCount(0); }); - test("live chat tab appears when the subs manifest reports liveChatTotalCount > 0", async ({ + test("live chat hint appears when the subs manifest reports liveChatTotalCount > 0", async ({ page, }) => { await installLiveChatRoutes(page); @@ -307,17 +326,13 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = await waitForHydration(page); await expect( - page.getByRole("tab", { name: /Live chat/ }), - ).toBeVisible(); - await expect( - page.getByRole("tab", { name: "Transcripts" }), + page.getByText(/Live chat available on 1 video/), ).toBeVisible(); }); test("filter checkboxes are grouped under Type / Audience / Availability labels", async ({ page, }) => { - // Trigger search so the result area renders alongside the filter row. await search(page, "platypus"); await expect(page.getByText("Type", { exact: true })).toBeVisible(); @@ -326,10 +341,7 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = page.getByText("Availability", { exact: true }), ).toBeVisible(); - // Each labeled group contains its expected checkboxes. - await expect( - page.getByRole("checkbox", { name: "Videos" }), - ).toBeChecked(); + await expect(page.getByRole("checkbox", { name: "Videos" })).toBeChecked(); await expect( page.getByRole("checkbox", { name: "Available" }), ).toBeChecked(); @@ -341,127 +353,83 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = ).toBeChecked(); }); - test("title matches are surfaced as a 'title' badge row above transcript hits", async ({ - page, - }) => { - await search(page, "platypus"); + 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"); - // Group headers include the channel + date suffix; match those specifically - // so we don't collide with the title-row buttons. - const both = page.getByRole("button", { - name: /Platypus facts and figures.*Test Channel/, - }); - await expect(both).toBeVisible(); - - const titleOnly = page.getByRole("button", { - name: /All about the platypus.*Test Channel/, - }); - await expect(titleOnly).toBeVisible(); - - // 'title' badge appears once per title-matched video. With deleted included - // by default, that's v-both, v-title-only, v-deleted-title. - const titleBadges = page.getByText("title", { exact: true }); - await expect(titleBadges).toHaveCount(3); + 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 both title and transcript matches", async ({ + test("'Deleted' unchecked hides deleted videos from results", async ({ page, }) => { await search(page, "platypus"); - // Sanity: by default, deleted videos are included. + // The deleted video with a transcript match is shown by default. await expect( - page.getByRole("button", { - name: /Deleted platypus chronicles.*Test Channel/, - }), + resultCard(page, /Vanished video on aquatic life.*Test Channel/), ).toBeVisible(); await page.getByRole("checkbox", { name: "Deleted" }).uncheck(); - await page.getByPlaceholder("Search transcripts...").press("Enter"); + await page.getByTestId("search-submit").click(); await expect( - page.getByRole("button", { name: /Deleted platypus chronicles/ }), - ).toHaveCount(0); - await expect( - page.getByRole("button", { name: /Vanished video on aquatic life/ }), + resultCard(page, /Vanished video on aquatic life/), ).toHaveCount(0); // Non-deleted matches remain. await expect( - page.getByRole("button", { - name: /Platypus facts and figures.*Test Channel/, - }), + resultCard(page, /Platypus facts and figures.*Test Channel/), ).toBeVisible(); }); - test("'Available' unchecked narrows to deleted-only videos", async ({ + test("'Available' unchecked narrows to non-available videos", async ({ page, }) => { await search(page, "platypus"); await page.getByRole("checkbox", { name: "Available" }).uncheck(); - await page.getByPlaceholder("Search transcripts...").press("Enter"); + await page.getByTestId("search-submit").click(); - // Deleted videos remain. + // Deleted + unlisted matches remain (separate buckets). await expect( - page.getByRole("button", { - name: /Deleted platypus chronicles.*Test Channel/, - }), + resultCard(page, /Vanished video on aquatic life.*Test Channel/), ).toBeVisible(); await expect( - page.getByRole("button", { - name: /Vanished video on aquatic life.*Test Channel/, - }), + resultCard(page, /Hidden mammal documentary.*Test Channel/), ).toBeVisible(); - // Non-deleted videos drop out. - await expect( - page.getByRole("button", { name: /Platypus facts and figures/ }), - ).toHaveCount(0); + // Public videos drop out. await expect( - page.getByRole("button", { name: /All about the platypus/ }), + 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, }) => { await search(page, "platypus"); - const unlisted = page.getByRole("button", { - name: /Hidden mammal documentary.*Test Channel/, - }); - // Default: unlisted videos are included. - await expect(unlisted).toBeVisible(); - - await page.getByRole("checkbox", { name: "Unlisted" }).uncheck(); - await page.getByPlaceholder("Search transcripts...").press("Enter"); - - await expect(unlisted).toHaveCount(0); - // Available (public) matches are unaffected. await expect( - page.getByRole("button", { - name: /Platypus facts and figures.*Test Channel/, - }), + resultCard(page, /Hidden mammal documentary.*Test Channel/), ).toBeVisible(); - }); - test("'Available' unchecked keeps unlisted videos (separate bucket)", async ({ - page, - }) => { - await search(page, "platypus"); - await page.getByRole("checkbox", { name: "Available" }).uncheck(); - await page.getByPlaceholder("Search transcripts...").press("Enter"); + await page.getByRole("checkbox", { name: "Unlisted" }).uncheck(); + await page.getByTestId("search-submit").click(); - // Unlisted is its own bucket, so it survives excluding "Available". - await expect( - page.getByRole("button", { - name: /Hidden mammal documentary.*Test Channel/, - }), - ).toBeVisible(); - // Public videos drop out. await expect( - page.getByRole("button", { name: /Platypus facts and figures/ }), + 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 tab", () => { +test.describe("export TranscriptSearch — live chat scope", () => { test.beforeEach(async ({ page }) => { await installFixtureRoutes(page); await installLiveChatRoutes(page); @@ -469,40 +437,19 @@ test.describe("export TranscriptSearch — live chat tab", () => { await waitForHydration(page); }); - test("track filter UI is not shown in live chat mode", async ({ page }) => { - await page.getByRole("tab", { name: /Live chat/ }).click(); - // The old per-track checkbox row is gone — no "Tracks" label, no - // per-track checkboxes. - await expect(page.getByText("Tracks", { exact: true })).toHaveCount(0); - await expect( - page.getByRole("checkbox", { name: "en", exact: true }), - ).toHaveCount(0); - await expect( - page.getByRole("checkbox", { name: "live chat", exact: true }), - ).toHaveCount(0); - }); - - test("only live_chat track results are surfaced", async ({ page }) => { - await page.getByRole("tab", { name: /Live chat/ }).click(); - // Wait for mode to commit to URL before searching. - await page.waitForURL(/[?&]m=subs/); - - const input = page.getByPlaceholder("Search transcripts..."); - await input.click(); - await input.fill("platypus"); - await input.press("Enter"); - await page.waitForURL(/[?&]q=platypus/); + test("only live_chat track results are surfaced in live chat scope", async ({ + page, + }) => { + await searchInScope(page, "Live chat", "platypus"); - // The "live chat" TrackBadge appears for the live_chat cue. The - // captioned-English cue should never appear, even though it also - // contains "platypus". - await expect(page.getByText("live chat", { exact: true })).toHaveCount(1); + // 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); - await expect( - page.getByText("viewer: platypus in chat!"), - ).toHaveCount(1); }); }); @@ -552,10 +499,8 @@ test.describe("export TranscriptSearch — advanced options persistence", () => }); test("Advanced reset does not touch filter checkboxes", async ({ page }) => { - // Set up a filter change first. await search(page, "platypus"); await page.getByRole("checkbox", { name: "Deleted" }).uncheck(); - // Then change Advanced. await page.locator("summary").filter({ hasText: "Advanced options" }).click(); const maxHits = page.getByRole("spinbutton", { name: "Max hits per batch", @@ -563,11 +508,9 @@ test.describe("export TranscriptSearch — advanced options persistence", () => await maxHits.fill("250"); await maxHits.press("Tab"); - // Reset Advanced. await page.getByRole("button", { name: "Reset to defaults" }).click(); await expect(maxHits).toHaveValue("500"); - // The filter state is unaffected — Deleted stays unchecked. await expect( page.getByRole("checkbox", { name: "Deleted" }), ).not.toBeChecked(); @@ -623,7 +566,7 @@ test.describe("export footer", () => { await waitForHydration(page); }); - test("footer holds downloads + social links sourced from settings", async ({ + test("footer holds downloads + social links sourced from the site", async ({ page, }) => { const footer = page.locator("footer"); @@ -632,7 +575,8 @@ test.describe("export footer", () => { footer.getByRole("link", { name: "code.tar.gz" }), ).toBeVisible(); - // The test settings file ships a single GitHub social link. + // 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( @@ -640,7 +584,6 @@ test.describe("export footer", () => { "https://github.com/example/repo", ); await expect(social).toHaveAttribute("target", "_blank"); - // The inlined SVG has been normalized — no width/height attrs. const svgHtml = await social.innerHTML(); expect(svgHtml).toContain("<svg"); expect(svgHtml).not.toMatch(/<svg[^>]*\swidth=/); @@ -659,58 +602,36 @@ test.describe("export TranscriptSearch — deferred search submit", () => { page, }) => { await search(page, "platypus"); - // The deleted video is present by default. - const deletedVideo = page.getByRole("button", { - name: /Deleted platypus chronicles.*Test Channel/, - }); + const deletedVideo = resultCard( + page, + /Vanished video on aquatic life.*Test Channel/, + ); await expect(deletedVideo).toBeVisible(); - // Toggle "Deleted" off — URL should NOT change to include nd=1 yet. + // Toggle "Deleted" off — the result is STILL rendered (deferred commit). await page.getByRole("checkbox", { name: "Deleted" }).uncheck(); - await expect(page).not.toHaveURL(/[?&]nd=1/); - // And the deleted video is STILL in the rendered results. await expect(deletedVideo).toBeVisible(); - // Dirty hint appears, Search button gets primary styling. + // Dirty hint appears. await expect( page.getByText("Press Enter or click Search to apply"), ).toBeVisible(); - // Clicking the Search button commits the filter change. - await page.getByRole("button", { name: "Search" }).click(); - await expect(page).toHaveURL(/[?&]nd=1/); + // Clicking Search commits the filter change. + await page.getByTestId("search-submit").click(); await expect(deletedVideo).toHaveCount(0); - // Dirty hint goes away. await expect( page.getByText("Press Enter or click Search to apply"), ).toHaveCount(0); }); - test("toggling Regex does not auto-commit and the placeholder previews regex mode", async ({ - page, - }) => { - const regex = page.getByRole("checkbox", { name: "Regex" }); - await regex.check(); - // The URL still has no re=1. - await expect(page).not.toHaveURL(/[?&]re=1/); - // The input placeholder reflects the *draft* regex mode. - await expect(page.getByPlaceholder(/Regex pattern/)).toBeVisible(); - // Dirty hint visible. - await expect( - page.getByText("Press Enter or click Search to apply"), - ).toBeVisible(); - - // Pressing Enter commits the draft. - await page.getByPlaceholder(/Regex pattern/).press("Enter"); - await expect(page).toHaveURL(/[?&]re=1/); - }); - test("Search button is the primary CTA when filters are dirty and muted otherwise", async ({ page, }) => { - const searchBtn = page.getByRole("button", { name: "Search" }); + await search(page, "platypus"); + const searchBtn = page.getByTestId("search-submit"); await expect(searchBtn).toBeVisible(); - // Pristine: outline/muted styling — no blue background. + // Pristine after commit: outline/muted styling — no blue background. await expect(searchBtn).not.toHaveClass(/bg-blue-600/); await page.getByRole("checkbox", { name: "Deleted" }).uncheck(); diff --git a/editor/e2e/fixtures/sites/testsite/site.json b/editor/e2e/fixtures/sites/testsite/site.json @@ -0,0 +1,17 @@ +{ + "siteId": "testsite", + "siteTitle": "Test Export Site", + "siteDescription": "Export e2e fixture site", + "headerTitle": "Test Export Site", + "homeTagline": "", + "socialLinks": [ + { + "label": "GitHub", + "url": "https://github.com/example/repo", + "svg": "<svg aria-hidden=\"true\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M12 2a10 10 0 0 0-3 19.5\"/></svg>" + } + ], + "groups": [{ "id": "default", "name": "All channels", "selectedByDefault": true }], + "defaultGroupId": "default", + "channels": [{ "slug": "test-channel", "groupId": "default" }] +} diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts @@ -15,6 +15,14 @@ const exportSettingsFile = path.resolve( process.cwd(), "test-settings.json", ); +// Footer/branding on the export server now come from a per-site config +// (currentSite()), so point it at a fixture site for deterministic E2E. +const exportSitesDir = path.resolve( + process.cwd(), + "e2e", + "fixtures", + "sites", +); export default defineConfig({ testDir: "./e2e", @@ -38,6 +46,8 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, env: { SETTINGS_FILE: exportSettingsFile, + SITES_DIR: exportSitesDir, + SITE_ID: "testsite", }, }, ],