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:
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",
},
},
],