import { expect, test } from "@playwright/test"; import { CHANNEL } from "./fixtures/data"; import { installRoutes, urlParams } from "./helpers"; test.use({ permissions: ["clipboard-read", "clipboard-write"], }); test.describe("search page — share current search", () => { test.beforeEach(async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await installRoutes(page); }); test("builds positive-encoded URL and strips filter params on commit", async ({ page, }) => { await page.goto("/"); // Wait for filter UI to render (gated on manifest load). const livestreamsBox = page.getByRole("checkbox", { name: "Livestreams" }); await expect(livestreamsBox).toBeVisible(); // Uncheck Livestreams + Deleted so the share link distinguishes selections. await livestreamsBox.uncheck(); await page.getByRole("checkbox", { name: "Deleted" }).uncheck(); // Type a query and run the search. const search = page.getByPlaceholder("Search transcripts..."); await search.fill("alpha"); await search.press("Enter"); // After commit the URL carries `qt=` (the composite-search tree) and // filter params are stripped. Legacy `q=` is cleared. await expect(page).toHaveURL(/[?&]qt=/); const after = await urlParams(page); expect(after.get("q")).toBeNull(); expect(after.get("qt")).not.toBeNull(); const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? "")); expect(tree.c[0].q).toBe("alpha"); expect(tree.c[0].s).toBe("transcripts"); // Click Share and inspect the clipboard. The share button lives inside // the Profile row now — scope the lookup so a future re-introduction of // a duplicate button elsewhere doesn't accidentally pass. const profilesRow = page.getByTestId("profiles-row"); await profilesRow .getByRole("button", { name: "Share current search" }) .click(); await expect( page.getByRole("button", { name: "Link copied!" }), ).toBeVisible(); const clip = await page.evaluate(() => navigator.clipboard.readText()); const url = new URL(clip); // v2: `fav` now carries one token per VideoState. The sentinel bump is // what lets a reader tell a deliberate new selection from a v1 link whose // author meant "everything that existed back then". expect(url.searchParams.get("fv")).toBe("2"); // Composite query is encoded in `qt=`; legacy `q=` is omitted. expect(url.searchParams.get("q")).toBeNull(); expect(url.searchParams.get("qt")).not.toBeNull(); const sharedTree = JSON.parse(url.searchParams.get("qt") ?? ""); expect(sharedTree.c[0].q).toBe("alpha"); // The only known channel is still selected (we only edited type/avail). expect(url.searchParams.getAll("fc")).toEqual([CHANNEL]); // Videos remains selected, Livestreams was unchecked. expect(url.searchParams.getAll("ft").sort()).toEqual(["v"]); // Audience: both selected by default. expect(url.searchParams.getAll("fa").sort()).toEqual(["a", "r"]); // Availability: Deleted unchecked → every other state remains // (a=available, m=unconfirmed, u=unlisted, p=private, o=members-only). expect(url.searchParams.getAll("fav").sort()).toEqual([ "a", "m", "o", "p", "u", ]); // Legacy filter params must NOT leak into the new link. expect(url.searchParams.get("ch")).toBeNull(); expect(url.searchParams.get("nol")).toBeNull(); expect(url.searchParams.get("nd")).toBeNull(); // Sharing from the Results view carries no chart-view params. expect(url.searchParams.get("view")).toBeNull(); expect(url.searchParams.get("cs")).toBeNull(); }); test("round-trips a v1 share link, then strips filter params on next commit", async ({ page, }) => { // Open a synthesized share link directly. const params = new URLSearchParams(); params.set("fv", "1"); params.append("fc", CHANNEL); params.append("ft", "v"); params.append("fa", "a"); params.append("fa", "r"); params.append("fav", "a"); params.set("q", "alpha"); await page.goto(`/?${params.toString()}`); // Wait for filter UI then verify hydration applied the v1 params. await expect( page.getByRole("checkbox", { name: "Livestreams" }), ).not.toBeChecked(); await expect(page.getByRole("checkbox", { name: "Videos" })).toBeChecked(); await expect(page.getByRole("checkbox", { name: "Deleted" })).not.toBeChecked(); await expect( page.getByRole("checkbox", { name: "Available" }), ).toBeChecked(); await expect(page.getByPlaceholder("Search transcripts...")).toHaveValue( "alpha", ); // Trigger another commit to confirm strip-on-commit behavior. await page.getByPlaceholder("Search transcripts...").press("Enter"); await expect(page).toHaveURL(/[?&]qt=/); const after = await urlParams(page); expect(after.get("fv")).toBeNull(); expect(after.get("fc")).toBeNull(); expect(after.get("ft")).toBeNull(); expect(after.get("fav")).toBeNull(); // Legacy `q=` is migrated to a single-leaf tree under `qt=`. expect(after.get("q")).toBeNull(); const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? "")); expect(tree.c[0].q).toBe("alpha"); }); test("legacy link still applies filters and is stripped on next commit", async ({ page, }) => { // Legacy disables videos via nov=1. After hydration the Videos checkbox // should be unchecked. const params = new URLSearchParams(); params.set("nov", "1"); params.set("nd", "1"); params.set("q", "hello"); await page.goto(`/?${params.toString()}`); await expect(page.getByRole("checkbox", { name: "Videos" })).not.toBeChecked(); await expect(page.getByRole("checkbox", { name: "Deleted" })).not.toBeChecked(); await expect( page.getByRole("checkbox", { name: "Livestreams" }), ).toBeChecked(); // Re-commit; legacy filter params are stripped, and `q=` migrates to // a single-leaf tree under `qt=`. await page.getByPlaceholder("Search transcripts...").press("Enter"); await expect(page).toHaveURL(/[?&]qt=/); const after = await urlParams(page); expect(after.get("nov")).toBeNull(); expect(after.get("nd")).toBeNull(); expect(after.get("q")).toBeNull(); const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? "")); expect(tree.c[0].q).toBe("hello"); }); });