commit 5fa4dd3bf8b750facdf32fddce173c0495082631
parent a94ccf323e8524ce9df2307d7a11d9edc004cafd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 16:59:48 -0400
export(e2e): search-in.spec — the Search in row; the fixture's posts get words of their own
search-in.spec.ts (new, 9 cases): the row's defaults and the hint; a plain
query reads posts, and unticking Posts drops them; one plain leaf reads both
corpora; Live chat ticked reads the chat, every hit badged, the URL still the
visitor's tree; Transcripts off; nothing ticked refuses Search and Enter;
the row survives a reload and a saved profile; an empty query lists with
Transcripts off; a leaf asked for by name ignores the row.
The fixture's posts said "alpha" and "gamma" like every video's cues, so
with posts read by default every spec that searches those words for its own
reasons got two post cards (17 failures in a first full run, in ask-chat,
ask-workspace, posts-search and query-tree). The posts now say "kappa" and
"sigma"; posts-search and tag-chips name them in their posts leaves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 294 insertions(+), 17 deletions(-)
diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts
@@ -462,8 +462,12 @@ export function subsPage() {
}
// ─── Social-post corpus fixtures ───
-// "alpha" appears in BOTH the transcript cues and a post body, so a combined
-// (transcripts OR posts) query is provably returning results from both corpora.
+// The posts have words of their own ("kappa", "sigma", "omega"), in no video's
+// cues, title or chat. Since release 16 a plain query reads posts by default
+// ("Search in": Transcripts and Posts ticked), and when the posts said "alpha"
+// and "gamma" like every video's cues, every spec that searches those words
+// for its own reasons got two post cards it was not about. A query that reads
+// both corpora names a word from each (posts-search.spec, search-in.spec).
function makePost(
id: string,
@@ -523,17 +527,17 @@ export function channelPostsManifest() {
export function postsPage() {
return [
- makePost(POST_ROOT_ID, "a post about alpha things", {
+ makePost(POST_ROOT_ID, "a post about kappa things", {
links: ["https://example.com/linked"],
engagement: { likes: 12, reposts: 3, replies: 1 },
}),
- makePost(POST_REPLY_ID, "replying about alpha again", {
+ makePost(POST_REPLY_ID, "replying about kappa again", {
isReply: true,
threadId: POST_ROOT_ID,
replyTo: { platform: "bluesky", id: POST_ROOT_ID },
createdAt: "2026-02-03T11:00:00.000Z",
}),
- makePost(POST_DELETED_ID, "a deleted gamma post", {
+ makePost(POST_DELETED_ID, "a deleted sigma post", {
isDeleted: true,
availability: "deleted",
availabilityCheckedAt: "2026-02-04T00:00:00.000Z",
diff --git a/export/e2e/posts-search.spec.ts b/export/e2e/posts-search.spec.ts
@@ -15,6 +15,11 @@ import { installRoutes } from "./helpers";
// The social-post corpus as a PARALLEL dataset to video transcripts: one
// search, one result set, with a toggle-able mode. Seeded through `?qt=`
// (the composite query tree) exactly like query-tree.spec.ts.
+//
+// The posts say "kappa" (two of them), "sigma" (the deleted one) and "omega";
+// every video's cues say "alpha". Since release 16 a plain query — a
+// "transcripts" leaf — reads posts too while Posts is ticked under Search in
+// (search-in.spec.ts); a leaf whose scope is "Posts" reads them by name.
type SLeaf = {
k: "l";
@@ -63,7 +68,7 @@ test.describe("social-post corpus — search", () => {
const tree: SGroup = {
k: "g",
o: "AND",
- c: [{ k: "l", q: "alpha", s: "posts" }],
+ c: [{ k: "l", q: "kappa", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]);
@@ -84,7 +89,7 @@ test.describe("social-post corpus — search", () => {
o: "OR",
c: [
{ k: "l", q: "alpha", s: "transcripts" },
- { k: "l", q: "alpha", s: "posts" },
+ { k: "l", q: "kappa", s: "posts" },
],
};
await page.goto(`/?qt=${qt(tree)}`);
@@ -101,7 +106,7 @@ test.describe("social-post corpus — search", () => {
const tree: SGroup = {
k: "g",
o: "AND",
- c: [{ k: "l", q: "alpha", s: "posts" }],
+ c: [{ k: "l", q: "kappa", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]);
@@ -133,7 +138,7 @@ test.describe("social-post corpus — search", () => {
const tree: SGroup = {
k: "g",
o: "AND",
- c: [{ k: "l", q: "alpha", s: "posts" }],
+ c: [{ k: "l", q: "kappa", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]);
@@ -147,7 +152,7 @@ test.describe("social-post corpus — search", () => {
await expect(modal).toBeVisible();
// Appears twice by design: as the primary post and again in its thread.
await expect(
- modal.getByText("a post about alpha things").first(),
+ modal.getByText("a post about kappa things").first(),
).toBeVisible();
// Thread context: the reply is archived under the same threadId.
await expect(modal.getByText(/Thread \(2 posts\)/)).toBeVisible();
@@ -157,13 +162,13 @@ test.describe("social-post corpus — search", () => {
).toBeVisible();
});
- test("the Posts type toggle switches the corpus off", async ({ page }) => {
+ test("the Posts box switches the corpus off", async ({ page }) => {
const tree: SGroup = {
k: "g",
o: "OR",
c: [
{ k: "l", q: "alpha", s: "transcripts" },
- { k: "l", q: "alpha", s: "posts" },
+ { k: "l", q: "kappa", s: "posts" },
],
};
await page.goto(`/?qt=${qt(tree)}`);
@@ -175,7 +180,9 @@ test.describe("social-post corpus — search", () => {
POST_REPLY_SLUG,
]);
- // Posts are a third media kind beside Videos / Livestreams.
+ // The Posts box sits under Search in (release 16; it was in the Type row,
+ // same key `nop`). Unticked, it also keeps the posts corpus out of the
+ // scope a "Posts" leaf reads, as it always has.
await page.getByRole("checkbox", { name: "Posts" }).uncheck();
await page.getByTestId("search-submit").click();
@@ -197,7 +204,7 @@ test("a deleted post is flagged in results and in the modal", async ({ page }) =
o: "AND",
// A term unique to the deleted fixture, so the other specs' expected
// result sets stay untouched.
- c: [{ k: "l", q: "gamma", s: "posts" }],
+ c: [{ k: "l", q: "sigma", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
const deletedSlug = `${POST_CHANNEL_SLUG}/${POST_DELETED_ID}`;
diff --git a/export/e2e/search-in.spec.ts b/export/e2e/search-in.spec.ts
@@ -0,0 +1,266 @@
+import { expect, test, type Page } from "@playwright/test";
+import {
+ CHANNEL_SLUG,
+ POST_CHANNEL_SLUG,
+ POST_REPLY_ID,
+ POST_ROOT_ID,
+ VIDEO_CHAT_LARGE,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+import { installRoutes, openFilters, showAll } from "./helpers";
+
+// Release 16, slice CK: the Filters panel's "Search in" row — Transcripts,
+// Posts, Live chat — says what a plain query (a "transcripts" leaf) reads.
+// Transcripts and Posts are on by default, Live chat off; a leaf asked for by
+// name in the builder is not the row's business; an empty query still lists
+// what the Type row says.
+//
+// The fixture: every video's cues say "… — alpha line" (and beta, gamma); two
+// posts say "kappa", which no video does; only VIDEO_CHAT_SMALL's live chat
+// says "message".
+
+const STORAGE_KEY = "ytdlp-tb:export-filters";
+
+const TRANSCRIPT_ONLY_SLUG = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`;
+const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`;
+const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`;
+const VIDEO_SLUGS = [TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG];
+const POST_SLUGS = [
+ `${POST_CHANNEL_SLUG}/${POST_ROOT_ID}`,
+ `${POST_CHANNEL_SLUG}/${POST_REPLY_ID}`,
+];
+
+const row = (page: Page) => page.getByTestId("search-in-row");
+const box = (page: Page, name: "Transcripts" | "Posts" | "Live chat") =>
+ row(page).getByRole("checkbox", { name, exact: true });
+const leafInput = (page: Page) =>
+ page.locator('input[data-testid^="leaf-query-"]').first();
+
+async function expectResultSlugs(page: Page, slugs: string[]) {
+ const cards = page.locator("[data-card-header]");
+ await expect(async () => {
+ const got = await cards.evaluateAll((els) =>
+ els.map((e) => e.getAttribute("data-result-slug") ?? ""),
+ );
+ expect(got.slice().sort()).toEqual(slugs.slice().sort());
+ }).toPass({ timeout: 15_000 });
+}
+
+async function search(page: Page, q: string) {
+ await leafInput(page).fill(q);
+ await page.getByTestId("search-submit").click();
+}
+
+test.describe("Search in", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("by default Transcripts and Posts are ticked and Live chat is not", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await expect(box(page, "Transcripts")).toBeChecked();
+ await expect(box(page, "Posts")).toBeChecked();
+ await expect(box(page, "Live chat")).not.toBeChecked();
+ // Posts moved here from the Type row: one box of that name on the page.
+ await expect(page.getByRole("checkbox", { name: "Posts", exact: true })).toHaveCount(1);
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeVisible();
+ await expect(page.getByRole("checkbox", { name: "Livestreams" })).toBeVisible();
+ // The bar's hint points at the row, and goes once the box is ticked.
+ const hint = page.getByText(/Live chat available on 2 videos — tick Live chat under Search in/);
+ await expect(hint).toBeVisible();
+ await box(page, "Live chat").check();
+ await expect(hint).toHaveCount(0);
+ await expect(page.getByTestId("search-in-empty")).toHaveCount(0);
+ });
+
+ test("a plain query reads posts by default; unticking Posts drops them", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await search(page, "kappa");
+ await expectResultSlugs(page, POST_SLUGS);
+ // A post's section says what it holds, not the leaf's "Transcripts".
+ await expect(
+ page.locator(`[data-result-slug="${POST_SLUGS[0]}"] [data-leaf-section]`),
+ ).toContainText("Posts");
+
+ await openFilters(page);
+ await box(page, "Posts").uncheck();
+ await page.getByTestId("search-submit").click();
+ await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)");
+ });
+
+ test("one plain leaf reads both corpora into one result set", async ({ page }) => {
+ // A regex leaf of scope "transcripts" — the default leaf — with no posts
+ // leaf beside it: the videos by their cues, the posts by their bodies.
+ const tree = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "alpha|kappa", s: "transcripts", r: 1 }],
+ };
+ await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(tree))}`);
+ await expectResultSlugs(page, [...VIDEO_SLUGS, ...POST_SLUGS]);
+ await expect(page.getByTestId("results-summary")).toHaveText(
+ "Matching videos (5 videos, 5 hits)",
+ );
+ });
+
+ test("ticking Live chat reads the chat: hits appear, badged live chat", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ // "message" is only in VIDEO_CHAT_SMALL's live chat: by default, nothing.
+ await search(page, "message");
+ await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)");
+
+ await openFilters(page);
+ await box(page, "Live chat").check();
+ await page.getByTestId("search-submit").click();
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ await expect(page.getByTestId("results-summary")).toHaveText(
+ "Matching videos (1 video, 30 hits)",
+ );
+ const card = page.locator(`[data-result-slug="${CHAT_SMALL_SLUG}"]`);
+ // Every hit shown wears the track badge; the section says Live chat.
+ const hits = card.locator("[data-leaf-section] + ul > li");
+ const badges = card.getByText("live chat", { exact: true });
+ await expect(badges.first()).toBeVisible();
+ expect(await badges.count()).toBe(await hits.count());
+ await expect(card.locator("[data-leaf-section]")).toContainText("Live chat");
+ // The URL carries the query the visitor built, not the rewrite.
+ const qt = new URL(page.url()).searchParams.get("qt") ?? "";
+ expect(JSON.parse(qt)).toEqual({
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "message", s: "transcripts" }],
+ });
+ });
+
+ test("with Transcripts off and Live chat on, a word only in the cues finds nothing", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await box(page, "Transcripts").uncheck();
+ await box(page, "Live chat").check();
+
+ // "line" is in every video's cues and nowhere else.
+ await search(page, "line");
+ await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)");
+ await expect(page.locator("[data-card-header]")).toHaveCount(0);
+
+ await search(page, "message");
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+
+ // With Transcripts back on, "line" finds all three again.
+ await box(page, "Transcripts").check();
+ await search(page, "line");
+ await expectResultSlugs(page, VIDEO_SLUGS);
+ });
+
+ test("with nothing ticked Search is refused and the row says so", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await leafInput(page).fill("kappa");
+ await box(page, "Transcripts").uncheck();
+ await box(page, "Posts").uncheck();
+ // Live chat is off by default: nothing is ticked now.
+ const submit = page.getByTestId("search-submit");
+ const empty = page.getByTestId("search-in-empty");
+ await expect(submit).toBeDisabled();
+ await expect(empty).toHaveText("Search in: pick at least one");
+ await expect(page.getByText("Press Enter or click Search to apply")).toHaveCount(0);
+ // Enter does not commit it either.
+ await leafInput(page).press("Enter");
+ await page.waitForTimeout(500);
+ expect(new URL(page.url()).searchParams.get("qt")).toBeNull();
+ await expect(page.getByTestId("results-summary")).toHaveCount(0);
+
+ await box(page, "Live chat").check();
+ await expect(submit).toBeEnabled();
+ await expect(empty).toHaveCount(0);
+ await box(page, "Live chat").uncheck();
+ await expect(submit).toBeDisabled();
+ await box(page, "Posts").check();
+ await expect(submit).toBeEnabled();
+ await submit.click();
+ await expectResultSlugs(page, POST_SLUGS);
+ });
+
+ test("the row survives a reload and a saved profile", async ({ page }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await box(page, "Transcripts").uncheck();
+ await box(page, "Live chat").check();
+ await search(page, "message");
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+
+ await page.reload();
+ await openFilters(page);
+ await expect(box(page, "Transcripts")).not.toBeChecked();
+ await expect(box(page, "Posts")).toBeChecked();
+ await expect(box(page, "Live chat")).toBeChecked();
+
+ // Save the row as a profile.
+ page.once("dialog", (d) => d.accept("chat only"));
+ await page.getByRole("button", { name: "Save as…" }).click();
+ await expect(page.getByTestId("profile-select")).toHaveValue("chat only");
+ await expect(page.getByTestId("profile-dirty-dot")).toHaveCount(0);
+
+ // Back to the default row and Search: the commit leaves the profile.
+ await box(page, "Transcripts").check();
+ await box(page, "Live chat").uncheck();
+ await page.getByTestId("search-submit").click();
+ await expect(page.getByTestId("profile-select")).toHaveValue("");
+
+ await page.reload();
+ await openFilters(page);
+ await expect(box(page, "Transcripts")).toBeChecked();
+ await expect(box(page, "Live chat")).not.toBeChecked();
+
+ // Loading the profile brings its row back, and it does not read as changed.
+ await page.getByTestId("profile-select").selectOption("chat only");
+ await expect(box(page, "Transcripts")).not.toBeChecked();
+ await expect(box(page, "Posts")).toBeChecked();
+ await expect(box(page, "Live chat")).toBeChecked();
+ await expect(page.getByTestId("profile-dirty-dot")).toHaveCount(0);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+
+ // Stored off the default only: `notr` and `lc`, and no `nop`.
+ const stored = await page.evaluate(
+ (key) => JSON.parse(window.localStorage.getItem(key) ?? "null"),
+ STORAGE_KEY,
+ );
+ const profile = stored.profiles["chat only"];
+ expect(profile.notr).toBe(true);
+ expect(profile.lc).toBe(true);
+ expect("nop" in profile).toBe(false);
+ });
+
+ test("an empty query with Transcripts off still lists every video", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await box(page, "Transcripts").uncheck();
+ await showAll(page);
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (3)");
+ await expectResultSlugs(page, VIDEO_SLUGS);
+ });
+
+ test("a leaf asked for by name is not changed by the row", async ({ page }) => {
+ // A "Live chat" leaf reads the chat with the row at its default (Live
+ // chat unticked), and a "Posts" leaf reads posts with Transcripts off.
+ const chatLeaf = { k: "g", o: "AND", c: [{ k: "l", q: "message", s: "chat" }] };
+ await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(chatLeaf))}`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ await openFilters(page);
+ await expect(box(page, "Live chat")).not.toBeChecked();
+ });
+});
diff --git a/export/e2e/tag-chips.spec.ts b/export/e2e/tag-chips.spec.ts
@@ -239,10 +239,10 @@ test.describe("curated tag chips", () => {
// the assertion because the header is where it showed: N videos and a
// hit count that no card on the page accounts for.
//
- // "chat" matches all three video titles; "alpha" matches two posts.
+ // "chat" matches all three video titles; "kappa" matches two posts.
const tree = qt([
{ q: "chat", s: "metadata" },
- { q: "alpha", s: "posts" },
+ { q: "kappa", s: "posts" },
]);
await page.goto(`/?qt=${tree}&tg=${TAG_COLLAB}`);
await waitForHydration(page);
@@ -263,7 +263,7 @@ test.describe("curated tag chips", () => {
// tag filter, never by this change.
const tree = qt([
{ q: "chat", s: "metadata" },
- { q: "alpha", s: "posts" },
+ { q: "kappa", s: "posts" },
]);
await page.goto(`/?qt=${tree}`);
await waitForHydration(page);