diff --git a/internal/lib/browser.mjs b/internal/lib/browser.mjs index bdd2145ea..41c262848 100644 --- a/internal/lib/browser.mjs +++ b/internal/lib/browser.mjs @@ -13,7 +13,12 @@ * DEEP_FIND_SRC page-side `deepFind(root, sel)` source: querySelector that * recurses into every nested OPEN shadow root (composite MFEs * nest custom elements, each with its own shadow root). - * deepEvaluate(page, fn, arg) page.evaluate with deepFind bound: fn receives (deepFind, arg). + * DEEP_FIND_ALL_SRC page-side `deepFindAll(root, sel)`: the COUNTING counterpart — + * returns EVERY match across nested open shadow roots, deduped + * via a seen-Set (a node reachable through two roots counts once). + * deepEvaluate(page, fn, arg) page.evaluate with both bound: fn receives + * (deepFind, arg, deepFindAll). The third parameter is APPENDED, + * so every existing caller taking (deepFind, arg) is unaffected. * Composed in Node (no eval inside the page, so a host CSP * cannot break it). */ @@ -61,7 +66,38 @@ export const DEEP_FIND_SRC = `(root, sel) => { return null }` +// ── deepFindAll — the COUNTING counterpart to deepFind ─────────────────────────────────────── +// +// WHY THIS EXISTS (added 2026-09-22). deepFind returns the FIRST match and stops. Every +// count-based assertion needs all of them — how many rows, anchors, hints, canvases — and because +// this file offered no such helper, each ad-hoc probe hand-rolled its own shadow walker. Measured +// across one session of verification work, those hand-rolled walkers produced FOUR false results: +// +// · a naive walker double-visited through nested roots and reported 48 cards for 24; +// · a single-level querySelector found 0 anchors in a composite that had 155; +// · a `.rd-row` count included a DETAIL PANEL sharing that class, so a correct filter read as broken; +// · a ` { + const seen = _seen || new Set() + const out = [] + if (!root || !root.querySelectorAll) return out + for (const el of root.querySelectorAll(sel)) { if (!seen.has(el)) { seen.add(el); out.push(el) } } + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) out.push(...deepFindAll(el.shadowRoot, sel, seen)) + } + return out +}` + export function deepEvaluate(page, fn, arg) { - const composed = new Function('arg', `const deepFind = ${DEEP_FIND_SRC};\nreturn (${fn.toString()})(deepFind, arg)`) + const composed = new Function('arg', + `const deepFind = ${DEEP_FIND_SRC};\n` + + `const deepFindAll = ${DEEP_FIND_ALL_SRC};\n` + + `return (${fn.toString()})(deepFind, arg, deepFindAll)`) return page.evaluate(composed, arg) }