test(ui): the knob matrix — every control the round-4 defects lived behind

Six specs driving the user-reported controls the way a human does,
encoding the sweep rules COVERAGE.md now states: menus/modals assert
VISIBILITY (never DOM text — hidden menus' text is why the 2026-10-30
panel sweep certified dead controls), geometry asserts the z-ladder
via elementFromPoint and viewport containment via boundingBox, every
click must produce an observable outcome (PUT fired, class flipped,
chip text swapped) and zero new console problems — the silent
CSP-throw family (theme buttons, connective chip, invisible op menu)
now fails a test instead of surviving to a user's mouse.

Covers: theme picker (interpolation regression from eb57b720),
pencil-glyph vs card-body contract, builder field menu / op menu
(full non-empty vocabulary) / connective both directions, the
Smart→Episodes create-modal seed, the picker overlay's ladder and
height, and the chain-order select's persistence round-trip.

Fixtures self-create/self-delete under test-prefixed names; localStorage
token + selectedLibrary=__all__ are seeded because the API login
establishes only the cookie; run the folder with --workers=1 (the login
route rate-limits and all suites share testuser — a parallel worker's
login revokes another's mid-run).
This commit is contained in:
John O'Keefe committed 2026-10-31 15:40:21 -04:00
1 parent 8aa66a30fc
commit d6619cd0e1
2 files changed
+348

No files matched your search

+316
View File
@@ -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=<feed uuid with downloaded episodes>");
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<string> {
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<void> {
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<void> {
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();
});