doxa-maps/internal/lib/browser.mjs
doxa-maps 2015378fcd
Some checks are pending
build-gate / build (push) Waiting to run
Add deepFindAll to the shared browser helper, so counting probes stop being hand-rolled
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. Across one session of verification work those produced four false
results:

  a naive walker double-visited through nested roots: 48 cards for 24;
  a single-level querySelector found 0 anchors where there were 155;
  an .rd-row count included a detail panel sharing that class, so a correct
    filter read as broken;
  a bare "<script" prefix match counted the word scriptUrl in a comment and
    flagged a valid file.

Two of those nearly became bug reports against working code. A missing helper
does not stop anyone writing the check; it moves the check somewhere nobody
reviews.

The seen-Set dedupe is the load-bearing part: a node reachable through two
roots must count once, which is the first failure above.

Backward compatible by construction -- the third parameter is APPENDED, so
callbacks taking (deepFind, arg) are untouched. Verified rather than assumed:
a (deepFind, arg) callback still resolves; deepFindAll returns 24 cards with
returned === distinct; share-roundtrip-smoke (a real consumer) still passes;
check:a11y-button-names and check:parse both pass.
2026-09-22 06:19:13 +00:00

103 lines
6.1 KiB
JavaScript

/**
* internal/lib/browser.mjs — helpers shared by the Playwright smoke gates
* (plugin-gallery-smoke.mjs, share-roundtrip-smoke.mjs).
*
* launch(browserName, { label }) Firefox by default (house rule: never Google Chrome;
* Playwright's bundled Chromium is allowed via 'chromium').
* If Playwright's PINNED Firefox revision is missing, falls
* back to any cached ~/.cache/ms-playwright/firefox-* build;
* same fallback for the bundled Chromium (any cached
* chromium-<rev> build — still Playwright's, never
* Google Chrome).
* TELEMETRY console/network noise to ignore (Mapbox telemetry beacons).
* 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).
* 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).
*/
import { readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
import * as pw from 'playwright'
export const TELEMETRY = /events\.mapbox\.com/
const CACHE = join(homedir(), '.cache', 'ms-playwright')
// Newest cached build dir for a browser prefix ('firefox-', 'chromium-') whose binary exists.
function cachedBinary(prefix, ...rel) {
const cands = existsSync(CACHE) ? readdirSync(CACHE).filter(d => d.startsWith(prefix) && /^\d+$/.test(d.slice(prefix.length))).sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length))) : []
for (const d of cands) {
// chromium ships as chrome-linux64/ (newer) or chrome-linux/ (older)
const dirs = rel.length ? [rel] : existsSync(join(CACHE, d)) ? readdirSync(join(CACHE, d)).filter(s => s.startsWith('chrome-linux')).map(s => [s, 'chrome']) : []
for (const parts of dirs) { const bin = join(CACHE, d, ...parts); if (existsSync(bin)) return { dir: d, bin } }
}
return null
}
export async function launch(browserName = 'firefox', { label = 'browser' } = {}) {
const CHROMIUM_ARGS = ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader']
if (browserName === 'chromium') {
try { return await pw.chromium.launch({ args: CHROMIUM_ARGS }) } catch (e) {
const c = cachedBinary('chromium-')
if (c) { console.log(`[${label}] pinned Chromium missing — using cached ${c.dir}`); return pw.chromium.launch({ executablePath: c.bin, args: CHROMIUM_ARGS }) }
throw e
}
}
if (browserName !== 'firefox') throw new Error(`--browser ${browserName} is not allowed (firefox|chromium — Google Chrome is never used)`)
try { return await pw.firefox.launch() } catch (e) {
const c = cachedBinary('firefox-', 'firefox', 'firefox')
if (c) { console.log(`[${label}] pinned Firefox missing — using cached ${c.dir}`); return pw.firefox.launch({ executablePath: c.bin }) }
throw e
}
}
// Page-side. Recursion works because deepEvaluate binds it as `const deepFind = …`.
export const DEEP_FIND_SRC = `(root, sel) => {
if (!root || !root.querySelector) return null
const hit = root.querySelector(sel); if (hit) return hit
for (const el of root.querySelectorAll('*')) { if (el.shadowRoot) { const h = deepFind(el.shadowRoot, sel); if (h) return h } }
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 `<script` prefix match counted the word `scriptUrl` in a comment and flagged a valid file.
//
// Two of those nearly became bug reports against working code. A missing helper does not stop
// anyone writing the check — it just moves the check somewhere nobody reviews.
//
// SEEN-SET DEDUPE IS THE LOAD-BEARING PART. A node reachable through two roots must be counted
// ONCE; without the Set the count silently doubles, which is the first failure above.
export const DEEP_FIND_ALL_SRC = `(root, sel, _seen) => {
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};\n` +
`const deepFindAll = ${DEEP_FIND_ALL_SRC};\n` +
`return (${fn.toString()})(deepFind, arg, deepFindAll)`)
return page.evaluate(composed, arg)
}