diff --git a/tests/ui/COVERAGE.md b/tests/ui/COVERAGE.md index 32304b9b..f27e7fb6 100644 --- a/tests/ui/COVERAGE.md +++ b/tests/ui/COVERAGE.md @@ -92,3 +92,35 @@ in a real browser against the dev stack. Outcomes + reusable gotchas: with queue=collection: play links → chain hops keep the param; order controls appear ONLY in feed mode (player + feed page share the persisted override). + +## 2026-10-31 — round 4: user-reported control defects → knob matrix (automated) + +The panel sweep's blind spot, proven by the user's own two minutes: +DOM-text snapshots show HIDDEN menus' text — "the field menu opened" +was an illusion; the menus never VISIBLY opened, the op menu rendered +zero items, `closeCollectionModal` removed the hidden confirm dialog +instead of the modal, and the theme buttons died silently +(`selectTheme(opt.Name)` — templ gotcha #1, since the CSP migration). + +Now automated: `knob_matrix.spec.ts` (6 specs — theme picker PUTs, +pencil-glyph vs card body at real coordinates, builder field/op menus +with toBeVisible + non-empty items + connective both directions, the +Smart→Episodes create-modal seed, picker modal ladder+geometry via +`elementFromPoint`/`boundingBox`, chain-order select persistence). +Run: `cd tests/ui && BH_FEED= npx playwright test +--workers=1` — **`--workers=1` is load-bearing**: the login endpoint +rate-limits and every suite shares `testuser`, so a parallel worker's +login revokes another's mid-run (symptom: `session has expired` pages). + +Standing rules for every sweep from now on: +- Menus/modals assert VISIBILITY (toBeVisible / computed display / + screenshot), never DOM text; every toggle click has an asserted + observable outcome (request fired, class flipped, value changed). +- Geometry asserts where geometry is the bug (z-ladder via + `elementFromPoint`, sheet within viewport via `boundingBox`). +- Zero new console errors per interaction; the only tolerated noise is + the two cancelled-transition shapes swallowed by main.ts's gate — + new noise gets fixed at the source, never filtered. +- API-mutating tests seed localStorage token + selectedLibrary=__all__ + (the mutators read the bearer from localStorage; library-scoped views + correctly hide episodes). diff --git a/tests/ui/knob_matrix.spec.ts b/tests/ui/knob_matrix.spec.ts new file mode 100644 index 00000000..39fc173e --- /dev/null +++ b/tests/ui/knob_matrix.spec.ts @@ -0,0 +1,316 @@ +// 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(); +}); + +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(); +});