import { expect, test, type Page } from "@playwright/test"; import { CHANNEL_SLUG, POST_CHANNEL_SLUG, POST_DELETED_ID, POST_OTHER_ID, POST_REPLY_ID, POST_ROOT_ID, VIDEO_CHAT_LARGE, VIDEO_CHAT_SMALL, VIDEO_TRANSCRIPT_ONLY, } from "./fixtures/data"; 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"; i?: string; q: string; s: "transcripts" | "chat" | "posts" | "metadata" | "description" | "tags"; r?: 1; h?: 0; n?: 1; }; type SGroup = { k: "g"; i?: string; o: "AND" | "OR"; n?: 1; c: (SLeaf | SGroup)[]; }; function qt(root: SGroup): string { return encodeURIComponent(JSON.stringify(root)); } 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 POST_ROOT_SLUG = `${POST_CHANNEL_SLUG}/${POST_ROOT_ID}`; const POST_REPLY_SLUG = `${POST_CHANNEL_SLUG}/${POST_REPLY_ID}`; const POST_OTHER_SLUG = `${POST_CHANNEL_SLUG}/${POST_OTHER_ID}`; 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 }); } test.describe("social-post corpus — search", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); }); test("a posts-scope leaf returns post hits", async ({ page }) => { const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "kappa", s: "posts" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]); // The leaf section inside the card is labelled for the posts layer // (scoped to the results list — "Posts" also appears in the scope select). await expect( page.locator("[data-leaf-section]").getByText("Posts", { exact: true }).first(), ).toBeVisible(); }); test("a combined (transcripts OR posts) query returns both kinds in one result set", async ({ page, }) => { // This is the headline requirement: one search covering transcripts AND // posts seamlessly, which falls out of the existing tree algebra. const tree: SGroup = { k: "g", o: "OR", c: [ { k: "l", q: "alpha", s: "transcripts" }, { k: "l", q: "kappa", s: "posts" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, POST_ROOT_SLUG, POST_REPLY_SLUG, ]); }); test("post cards render no seek button", async ({ page }) => { const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "kappa", s: "posts" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]); // A post has no timeline, so its hit rows carry no mm:ss gutter — unlike a // transcript hit row, which always does. const postCard = page .locator(`[data-card-header][data-result-slug="${POST_ROOT_SLUG}"]`) .locator("xpath=ancestor::div[@data-card-header or @data-result-slug][1]"); await expect(postCard.getByText(/^\d+:\d{2}$/)).toHaveCount(0); }); test("qt= round-trips a posts leaf through the URL", async ({ page }) => { const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "omega", s: "posts" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [POST_OTHER_SLUG]); // The scope survived deserialize's explicit whitelist — a dropped leaf // would have matched everything (empty tree) instead of just this post. const select = page.locator('[data-testid^="leaf-scope-"]').first(); await expect(select).toHaveValue("posts"); }); test("opening a post result shows the post modal with its thread", async ({ page, }) => { const tree: SGroup = { k: "g", o: "AND", c: [{ k: "l", q: "kappa", s: "posts" }], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]); await page .locator(`[data-card-header][data-result-slug="${POST_ROOT_SLUG}"]`) .locator("[data-card-open]") .click(); const modal = page.locator("[data-post-modal]"); await expect(modal).toBeVisible(); // Appears twice by design: as the primary post and again in its thread. await expect( 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(); // Outbound links are surfaced, media is only counted. await expect( modal.getByRole("link", { name: "https://example.com/linked" }).first(), ).toBeVisible(); }); test("the Posts box leaves a \"Posts\" leaf reading posts", async ({ page }) => { const tree: SGroup = { k: "g", o: "OR", c: [ { k: "l", q: "alpha", s: "transcripts" }, { k: "l", q: "kappa", s: "posts" }, ], }; await page.goto(`/?qt=${qt(tree)}`); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, POST_ROOT_SLUG, POST_REPLY_SLUG, ]); // The Posts box sits under Search in (release 16; it was in the Type row, // same key `nop`) and says what a plain query reads. A leaf whose scope is // "Posts" was asked for by name, and still reads its two posts unticked. await page.getByRole("checkbox", { name: "Posts" }).uncheck(); await page.getByTestId("search-submit").click(); await expectResultSlugs(page, [ TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG, POST_ROOT_SLUG, POST_REPLY_SLUG, ]); }); }); test("a deleted post is flagged in results and in the modal", async ({ page }) => { // Defined outside the describe above, so it installs its own route mocks. await installRoutes(page); // Preserving posts that no longer exist upstream is the point of the // archive, so the deletion has to be visible rather than silent. const tree: SGroup = { k: "g", o: "AND", // A term unique to the deleted fixture, so the other specs' expected // result sets stay untouched. c: [{ k: "l", q: "sigma", s: "posts" }], }; await page.goto(`/?qt=${qt(tree)}`); const deletedSlug = `${POST_CHANNEL_SLUG}/${POST_DELETED_ID}`; await expectResultSlugs(page, [deletedSlug]); const card = page.locator(`[data-card-header][data-result-slug="${deletedSlug}"]`); await expect(card.getByText("Deleted", { exact: true })).toBeVisible(); await card.locator("[data-card-open]").click(); const modal = page.locator("[data-post-modal]"); await expect(modal).toBeVisible(); await expect(modal.getByText("deleted", { exact: true }).first()).toBeVisible(); await expect(modal.getByRole("link", { name: /Original \(deleted\)/ })).toBeVisible(); });