// The knob matrix (2026-10-31 round): every control the round touched, // clicked the way a user clicks, with the two assertion classes that // catch the whole silent-failure family: // 1. VISIBILITY after click — toBeVisible() on what should open. // (DOM-text snapshots show hidden menus' text; only visibility // catches the invisible-menu and instant-close bugs.) // 2. ZERO NEW console problems per interaction — the CSP build // rejects bad Alpine expressions SILENTLY at the click site; an // uncaught "Alpine Expression Error" is the fingerprint of every // dead control this round (theme buttons, connective chip). // Plus geometry where geometry was the bug (modal under the sidebar, // modal taller than the window). // // Env-gated against the dev server: BH_BASE, BH_FEED (any feed with // downloaded episodes for the chain/picker checks). Fixtures are // created via the API and deleted at the end. import { test, expect, type Page } from "@playwright/test"; const BASE = process.env.BH_BASE ?? "http://localhost:8765"; const FEED = process.env.BH_FEED ?? ""; test.skip(!FEED, "set BH_FEED="); let consoleErrors: string[] = []; // ONE endpoint login for the whole file; rls_sweep shares this server // and account, and the login route rate-limits rapid attempts (a sweep // must not look like brute force). Tests inject the cached session // (cookie + localStorage) instead of logging in again. let sessionToken = ""; async function loginEndpoint(): Promise { if (sessionToken) return sessionToken; const res = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ login: "testuser", password: "Test@Pass123!" }), }); const body = (await res.json()) as { access_token?: string }; if (!body.access_token) throw new Error("knob matrix login failed"); sessionToken = body.access_token; return sessionToken; } async function setupSession(page: Page): Promise { const token = await loginEndpoint(); // SSR auth rides the token cookie; the theme/chain-order mutators // read the bearer from localStorage (the interactive login stores it). await page.addInitScript((t) => localStorage.setItem("token", t), token); // Browse ALL libraries: under a single-library scope the switcher // contract correctly hides episodes (the empty state is right), and // the episodes fixtures here would never render. await page.context().addCookies([ { name: "token", value: token, url: BASE }, { name: "selectedLibrary", value: "__all__", url: BASE }, ]); } function watchConsole(page: Page): void { consoleErrors = []; page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); page.on("pageerror", (e) => consoleErrors.push(String(e))); } async function authedGoto(page: Page, path: string): Promise { await page.goto(`${BASE}${path}`); if (page.url().includes("/login")) { // A shared-account race (or an expired cache) — re-establish the // session once and continue. The endpoint login is re-fetched fresh. sessionToken = ""; await setupSession(page); await page.goto(`${BASE}${path}`); } } function expectNoNewConsoleErrors(): void { const real = consoleErrors.filter( (t) => !t.includes("Transition was skipped") && !t.includes("isFromCancelledTransition"), ); expect(real, `console errors: ${JSON.stringify(real)}`).toEqual([]); } test.describe.configure({ mode: "serial" }); let token = ""; let episodesCollectionID = ""; let staticCollectionID = ""; test.beforeAll(async ({ browser }) => { const page = await browser.newPage(); token = await loginEndpoint(); // Episodes-scope smart collection over BH_FEED (the scope door's own // seed shape). const res = await page.request.post(`${BASE}/api/collections`, { headers: { Authorization: `Bearer ${token}` }, data: { name: "test KnobMatrix episodes", collection_type: "smart", filter_tree: { scope: "episodes", mode: "all", children: [{ field: "feed", op: "in", values: [FEED] }], }, }, }); expect(res.status()).toBe(201); episodesCollectionID = (await res.json()).id; const stat = await page.request.post(`${BASE}/api/collections`, { headers: { Authorization: `Bearer ${token}` }, data: { name: "test KnobMatrix static", collection_type: "static" }, }); expect(stat.status()).toBe(201); staticCollectionID = (await stat.json()).id; await page.close(); }); test.afterAll(async ({ browser }) => { const page = await browser.newPage(); await loginEndpoint(); for (const id of [episodesCollectionID, staticCollectionID]) { if (id) { await page.request.delete(`${BASE}/api/collections/${id}`, { headers: { Authorization: `Bearer ${token}` }, }); } } await page.close(); }); test("theme picker: every click persists and flips the body class", async ({ page }) => { watchConsole(page); await setupSession(page); await authedGoto(page, "/collections"); const appearance = page.getByRole("button", { name: "Appearance" }); await appearance.click(); const dracula = page.locator('.theme-btn[data-theme="dracula"]'); await expect(dracula).toBeVisible(); const putTheme = page.waitForRequest( (r) => r.method() === "PUT" && r.url().includes("/api/auth/theme"), ); await dracula.click(); const req = await putTheme; expect(req.postDataJSON()).toEqual({ theme: "dracula" }); await expect.poll(() => page.locator("body").getAttribute("class")).toContain("theme-dracula"); expectNoNewConsoleErrors(); // Back to the default (the collection list must not stay repainted). // selectTheme CLOSES the panel after every pick — reopen first. await appearance.click(); const back = page.waitForRequest( (r) => r.method() === "PUT" && r.url().includes("/api/auth/theme"), ); await page.locator('.theme-btn[data-theme="tokyo-night"]').click(); await back; expectNoNewConsoleErrors(); // THE STRAND-CLASS REGRESSION PIN: the global 401 interceptor can // clear the localStorage bearer (session rotation); theme saves must // ride the token COOKIE (middleware TokenLookup checks cookie first) // — never gated on localStorage ("can no longer change themes", // 2026-10-31: the save early-returned without a bearer and every // reload re-rendered the old server theme). await page.evaluate(() => localStorage.removeItem("token")); await appearance.click(); const putNord = page.waitForRequest( (r) => r.method() === "PUT" && r.url().includes("/api/auth/theme"), ); await page.locator('.theme-btn[data-theme="nord"]').click(); expect((await putNord).postDataJSON()).toEqual({ theme: "nord" }); await expect.poll(() => page.locator("body").getAttribute("class")).toContain("theme-nord"); // Restore the default — also via cookie, proving persistence works // without any localStorage bearer at all. await appearance.click(); const putBack = page.waitForRequest( (r) => r.method() === "PUT" && r.url().includes("/api/auth/theme"), ); await page.locator('.theme-btn[data-theme="tokyo-night"]').click(); await putBack; expectNoNewConsoleErrors(); }); test("pencil glyph vs card body: modal stays, card navigates", async ({ page }) => { watchConsole(page); await setupSession(page); await authedGoto(page, "/collections"); const firstCard = page.locator("#collections-list > div").first(); // Click the PENCIL GLYPH (the svg inside the button) — the exact // target that used to sail past the HTMLElement guard and navigate. const glyph = firstCard.locator('button[aria-label="Edit collection"] svg'); await glyph.click(); await expect(page.locator("#modal-container")).toContainText("Edit Collection"); expect(page.url()).not.toContain("/collections/"); // still on the list expectNoNewConsoleErrors(); // Card body navigates to the builder/detail page. await page.locator("#modal-container button[aria-label='Close']").click(); // The card wrapper itself carries data-href; its center is the title // area, clear of the corner buttons. (Playwright's nested locator() // is descendants-only, so firstCard.locator("[data-href]") could // never match — the attribute is ON the wrapper, not inside it.) await firstCard.click(); await expect(page).toHaveURL(/\/collections\/[0-9a-f-]{36}/); expectNoNewConsoleErrors(); }); test("builder: field menu, op menu (full vocabulary), connective both ways", async ({ page }) => { watchConsole(page); await setupSession(page); await authedGoto(page, "/bookshelf"); await page.getByRole("button", { name: "Filters", exact: true }).click(); const fieldBtn = page.locator("button.fb-picker").first(); await expect(fieldBtn).toBeVisible(); // FIELD menu opens VISIBLY and a field is pickable. await fieldBtn.click(); const fieldMenu = page.locator(".fb-menu-fields").first(); await expect(fieldMenu).toBeVisible(); await fieldMenu.locator(".fb-menu-item").first().click(); await expect(fieldMenu).toBeHidden(); expectNoNewConsoleErrors(); // OP menu opens VISIBLY with the field's operators; pick one. const opBtn = page.locator("button.fb-picker-op").first(); await opBtn.click(); const opMenu = page.locator(".fb-picker-op + .fb-menu").first(); await expect(opMenu).toBeVisible(); const ops = opMenu.locator(".fb-menu-item"); expect(await ops.count()).toBeGreaterThan(0); // the invisible-menu bug await ops.first().click(); await expect(opMenu).toBeHidden(); expectNoNewConsoleErrors(); // Second rule → the connective chip appears and toggles AND → OR → AND. await page.getByRole("button", { name: "+ Rule" }).click(); const chip = page.locator(".fb-connective-btn:not(.fb-chip-static)").first(); await expect(chip).toBeVisible(); await expect(chip).toHaveText(/AND|OR/); const before = (await chip.textContent())?.trim(); await chip.click(); const after = (await chip.textContent())?.trim(); expect(after).not.toBe(before); // flipped await chip.click(); await expect(chip).toHaveText(before ?? ""); // flipped back expectNoNewConsoleErrors(); }); test("create modal: Smart reveals the scope door and seeds the episodes tree", async ({ page }) => { watchConsole(page); await setupSession(page); await authedGoto(page, "/collections"); await page.getByRole("button", { name: "New Collection" }).click(); // The radios are sr-only (visually hidden); a human clicks the LABEL. await page.locator('#modal-container label:has(input[value="smart"])').click(); const scopeRow = page.locator("#modal-container #collection-tree-seed"); await expect(scopeRow).toBeAttached(); await page.getByRole("button", { name: "🎧 Podcast episodes" }).click(); const hidden = page.locator("#collection-tree-seed"); const seed = await hidden.inputValue(); const parsed = JSON.parse(seed); expect(parsed).toEqual({ scope: "episodes", mode: "all", children: [] }); // Books scope clears the seed (tree-less = the books default). await page.getByRole("button", { name: "📚 Books" }).click(); expect(await hidden.inputValue()).toBe(""); expectNoNewConsoleErrors(); }); test("episodes picker modal: above the sidebar, inside the viewport", async ({ page }) => { watchConsole(page); await setupSession(page); await page.setViewportSize({ width: 1280, height: 720 }); await authedGoto(page, `/collections/${staticCollectionID}`); await page.getByRole("button", { name: "Add Items" }).click(); const overlay = page.locator('div[x-data="bookPicker"]'); await expect(overlay).toBeVisible(); // LADDER: the point over the SIDEBAR must hit the OVERLAY, not the // nav (the z-50-under-z-60 bug put the modal's left edge beneath it). const sidebar = page.locator(".app-sidebar"); const sb = await sidebar.boundingBox(); const ov = await overlay.boundingBox(); expect(sb).not.toBeNull(); expect(ov).not.toBeNull(); const hit = await page.evaluate( ([x, y]) => { const el = document.elementFromPoint(x as number, y as number); return el ? el.closest('div[x-data="bookPicker"]') !== null : false; }, [sb!.x + 20, sb!.y + 300] as [number, number], ); expect(hit, "the sidebar point must land on the picker overlay").toBe(true); // GEOMETRY: the dialog card fits the viewport (footer reachable). const card = overlay.locator(".card").first(); const cb = await card.boundingBox(); expect(cb).not.toBeNull(); expect(cb!.y + cb!.height).toBeLessThanOrEqual(720 + 1); expectNoNewConsoleErrors(); // Scope tabs: Episodes tab loads rows (downloaded badge present). await overlay.getByRole("button", { name: /Episodes/ }).click(); const epGrid = page.locator("#episode-picker-grid"); await expect(epGrid).toBeVisible(); await expect(epGrid.locator(".fb-menu-empty, div").first()).toBeVisible(); await page.keyboard.press("Escape"); await expect(overlay).toBeHidden(); expectNoNewConsoleErrors(); }); test("chain order select: flip persists and the chain honors it", async ({ page }) => { watchConsole(page); await setupSession(page); await authedGoto(page, `/collections/${episodesCollectionID}`); const select = page.locator("#chain-order-select"); await expect(select).toBeVisible(); // only renders with playable episodes const putOrder = page.waitForRequest( (r) => r.method() === "PUT" && r.url().includes("/view-settings"), ); await select.selectOption("newest"); const req = await putOrder; expect(req.postDataJSON()).toEqual({ chain_order: "newest" }); // The page reloads; the stored order renders as selected. await page.waitForLoadState("load"); await expect(select).toHaveValue("newest"); expectNoNewConsoleErrors(); });