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
@@ -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