fix(filters): operator table sent labels as wire values — every non-self-named operator was broken (user report)
The client's operator map passed the display string as BOTH the map key and the OpMeta.key, so the menu clicks transmitted 'doesn't contain' / 'isn't' / 'is empty' / 'at least' / … literally. The server rightly rejected them (unknown operator → 400), the count fetch's catch hid the counter, and flipping away from such an operator never came back. Only self-named ops (contains, is, has, between…) worked. - Map keys now DERIVE from meta.key (Object.fromEntries over the op list) — wire value and label structurally cannot diverge again. - New scripts/filter_builder_harness.js: a playwright harness that drives the real drawer in headless Chromium and sweeps every author operator, capturing filter-count responses and console errors — the first UI-level regression net for the builder (the server tests could never see a client-side mis-key). - Verified live: contains 8 → doesn't contain 60 → is 8 → isn't 60 → is empty 2 → refill 8; zero console errors across the sweep.
This commit is contained in:
1 parent
68701d6b94
commit
43987262a6
3 files changed
+123
-20
No files matched your search
@@ -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=<repo>/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); });
|
||||
Reference in new issue
Block a user