diff --git a/package.json b/package.json index 0033c8ad..d2e73fb5 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/markdown-it": "^14.2.0", "@types/turndown": "^5.0.6", "autoprefixer": "^10.4.27", + "playwright": "^1.64.1", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", "vite": "^7.3.1" diff --git a/scripts/filter_builder_harness.js b/scripts/filter_builder_harness.js new file mode 100644 index 00000000..d9c174e0 --- /dev/null +++ b/scripts/filter_builder_harness.js @@ -0,0 +1,96 @@ +// Filter-builder UI regression harness (faceted plan Phase 9 follow-up). +// +// Drives the real bookshelf in headless Chromium: opens the filter +// drawer, types an author value, and sweeps every author operator, +// asserting the live count fetch behaves (200s, correct numbers, no +// console errors). Born from the mis-keyed operator table that made +// every non-self-named operator (doesn't contain, is empty, at least…) +// a silent 400 — the class of bug only a real browser catches. +// +// Prerequisites: dev stack up (make dev / docker compose), playwright +// installed (cd web && npm i -D playwright && npx playwright install +// chromium — the repo root's devDependency already carries it), and a +// reachable server. +// +// Usage: NODE_PATH=/node_modules node scripts/filter_builder_harness.js +// Env: HARNESS_BASE (default http://localhost:8765), HARNESS_LOGIN, +// HARNESS_PASSWORD. +const { chromium } = require("playwright"); + +const BASE = process.env.HARNESS_BASE || "http://localhost:8765"; +const LOGIN = process.env.HARNESS_LOGIN || "testuser@tests.bookhoard.internal"; +const PASSWORD = process.env.HARNESS_PASSWORD || "Test@Pass123!"; +const AUTHOR = "Andrzej Sapkowski"; +const OPS_SWEEP = ["doesn't contain", "is", "isn't", "is empty", "contains"]; + + +(async () => { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + const events = []; + page.on("console", (m) => { + if (m.type() === "error" || m.type() === "warning") { + events.push(`[console.${m.type()}] ${m.text()}`); + } + }); + page.on("pageerror", (e) => events.push(`[pageerror] ${e.message}`)); + page.on("response", async (r) => { + if (r.url().includes("filter-count")) { + let body = ""; + try { body = (await r.text()).slice(0, 120); } catch {} + events.push(`[net] filter-count ${r.status()} ${body}`); + } + }); + + const login = await context.request.post(`${BASE}/api/auth/login`, { + data: { login: LOGIN, password: PASSWORD }, + }); + const loginBody = await login.json(); + await context.addInitScript((t) => localStorage.setItem("token", t), loginBody.access_token); + console.log("login:", login.status()); + + await page.goto(`${BASE}/bookshelf`); + await page.waitForLoadState("networkidle"); + await page.evaluate(() => { + const el = document.getElementById("library-select"); + const empty = [...el.options].find((o) => o.value === ""); + if (empty) { + el.value = ""; + el.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + await page.waitForTimeout(800); + + await page.click('button:has-text("Filters")'); + await page.waitForTimeout(300); + const valueInput = page.locator(".fb-value").first(); + await valueInput.click(); + await valueInput.fill(AUTHOR); + await page.waitForTimeout(1200); + const countEl = page.locator('span[x-show*="matchCount"]').first(); + console.log("contains:", JSON.stringify(await countEl.textContent())); + + for (const label of OPS_SWEEP) { + await page.locator(".fb-picker-op").first().click(); + await page.waitForTimeout(150); + await page + .locator(".fb-menu-item:visible") + .filter({ hasText: new RegExp(`^${label.replace(/[\W_]+/g, "\\$&")}$`) }) + .first() + .click(); + await page.waitForTimeout(1300); + console.log(`after "${label}":`, JSON.stringify(await countEl.textContent())); + } + + // contains-with-empty-value is incomplete (counter hidden) — refill + // and confirm the count returns. + await valueInput.fill(AUTHOR); + await page.waitForTimeout(1300); + console.log("refilled contains:", JSON.stringify(await countEl.textContent())); + + console.log("--- warnings/errors + count calls ---"); + for (const l of events) console.log(l); + await browser.close(); +})().catch((e) => { console.error("HARNESS ERROR:", e.message); process.exit(1); }); diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts index b12db2e8..ba169a2e 100644 --- a/web/src/filter_builder.ts +++ b/web/src/filter_builder.ts @@ -30,26 +30,32 @@ const op = ( arity: OpMeta["arity"], ): OpMeta => ({ key, label, negated, arity }); -export const OPS: Record = { - contains: op("contains", "contains", false, "many"), - not_contains: op("doesn't contain", "doesn't contain", true, "many"), - is: op("is", "is", false, "many"), - is_not: op("isn't", "isn't", true, "many"), - empty: op("is empty", "is empty", false, "none"), - is_true: op("is true", "is true", false, "none"), - is_false: op("is false", "is false", false, "none"), - between: op("between", "between", false, "two"), - at_least: op("at least", "at least", false, "one"), - at_most: op("at most", "at most", false, "one"), - within_days: op("within the last", "within the last", false, "one"), - has: op("has", "has", false, "none"), - missing: op("missing", "missing", false, "none"), - unrated: op("unrated", "unrated", false, "none"), - in: op("in", "in", false, "many"), - not_in: op("isn't in", "isn't in", true, "many"), - in_any: op("in any", "in any", false, "none"), - in_none: op("in none", "in none", false, "none"), -}; +// The map key DERIVES from meta.key — the wire value and the display +// label cannot drift apart again (a mis-keyed entry threw every +// non-self-named operator out of the allow-list: "doesn't contain" was +// being sent literally, and the server rightly refused it). +export const OPS: Record = Object.fromEntries( + [ + op("contains", "contains", false, "many"), + op("not_contains", "doesn't contain", true, "many"), + op("is", "is", false, "many"), + op("is_not", "isn't", true, "many"), + op("empty", "is empty", false, "none"), + op("is_true", "is true", false, "none"), + op("is_false", "is false", false, "none"), + op("between", "between", false, "two"), + op("at_least", "at least", false, "one"), + op("at_most", "at most", false, "one"), + op("within_days", "within the last", false, "one"), + op("has", "has", false, "none"), + op("missing", "missing", false, "none"), + op("unrated", "unrated", false, "none"), + op("in", "in", false, "many"), + op("not_in", "isn't in", true, "many"), + op("in_any", "in any", false, "none"), + op("in_none", "in none", false, "none"), + ].map((m) => [m.key, m]), +); export interface FieldMeta { key: string;