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:
1 parent
8aa66a30fc
commit
d6619cd0e1
2 files changed
+348
No files matched your search
@@ -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
|
with queue=collection: play links → chain hops keep the param; order
|
||||||
controls appear ONLY in feed mode (player + feed page share the
|
controls appear ONLY in feed mode (player + feed page share the
|
||||||
persisted override).
|
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=<feed uuid> 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).
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user