diff --git a/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs b/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs index 814497c31..2d80708a8 100644 --- a/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs +++ b/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs @@ -48,18 +48,38 @@ * reads the URL first) — the engine is Mapbox, it simply has no key. A gate that fails a * product for a key the gate did not provide is measuring its own environment. * It now takes MAPBOX_TOKEN from the env (or vite.doxa-maps-build-config.json `token`, the - * same ladder check:experimental-shelf uses) and injects it as window.MAP_TOKEN before - * navigation, the way check:auto-engine and check:a11y-button-names do. With no token the + * same ladder check:experimental-shelf uses) and supplies it on BOTH documented rungs — the + * host global (window.MAP_TOKEN, injected before navigation the way check:auto-engine does) + * and the ?tk= URL param — as two separate cases. See the CASES block below for why two. + * With no token the * mapbox half is NOT MEASURED — reported as a SKIP line so run-parallel prints ⊘ rather than * counting it as a pass, because an always-taken skip is indistinguishable from an unwired * gate unless the runner says so. * + * ★ WHY THE MAP PROBE ASKS THE ENGINE AND NOT THE DOM (2026-09-26, the second correction in a + * day). This gate reported "NO map constructed" on a leaf where rega then measured 1 Mapbox map, 2 + * GL canvases and 3 search inputs, on BOTH token rungs. The product was never at fault; the + * detection was. A DOM walk for a canvas is a chain of assumptions — every shadow root open, the + * canvas carrying the class expected, existing by the time it is looked for — and when any link + * breaks it reports ABSENCE, which is indistinguishable from a broken product. It now hooks the + * engine's own Map.prototype.setStyle through both globals, which cannot be wrong about the DOM + * because it never looks at it, and widens the bar selector to what a reader would call a search + * box. Both borrowed from rega's tokrung.mjs. + * + * THE LESSON, recorded because it is the second time in two days that a probe accused a working + * product: a measurement that yields ZERO when its own assumption is wrong will always blame the + * thing it is measuring. Where a positive control exists that measures correctly, copy the control. + * * ASSERTIONS, on the built tree: * maplibre ZERO requests to api.mapbox.com. (Measured with or without a token.) * If a bar is present it must produce suggestions; if it is absent, that is the * documented contract and is recorded as such. - * mapbox the bar is PRESENT and produces suggestions. Requires a token; without one this - * half is skipped, never failed. Mapbox traffic is expected here and not asserted on. + * mapbox TWICE, once per token rung: a map CONSTRUCTS, the bar is PRESENT, and typing + * produces suggestions. Requires a token; without one this half is skipped, never + * failed. Mapbox traffic is expected here and not asserted on — but rejected + * RESPONSES are recorded, because "21 requests and no map" is otherwise ambiguous + * between a token never seen, a token refused, and a map that failed for its own + * reasons, and those three have different owners. * * NEVER PRINTS THE TOKEN. Only its presence is reported. * @@ -116,18 +136,71 @@ const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '- const fails = [] const lines = [] try { - // The mapbox half needs a key. Measure maplibre either way — it is the headline claim and it is - // fully measurable keyless, which is the whole point of the engine. - const engines = TOKEN ? ['maplibre', 'mapbox'] : ['maplibre'] - for (const engine of engines) { + // ★ THE MAPBOX HALF IS MEASURED ON BOTH TOKEN RUNGS, AND THAT IS A DELIBERATE EXPERIMENT. + // rega, gates:post at 1a761741 WITH a token: the mapbox half reported "NO map constructed", 21 + // requests to api.mapbox.com, 0 bars — while rega's own probe of THIS leaf at + // ?provider=mapbox&tk= constructed a Mapbox map. The suggested cause was that the + // generated leaf overwrites the window.MAP_TOKEN this gate sets in addInitScript. + // + // IT DOES NOT. research-map/index.html reads it at line 70 (`HOST_TOKEN = window.MAP_TOKEN`) and + // every assignment after it — lines 71, 109, 150 — is guarded by `!window.MAP_TOKEN`, so a + // host-set value is read and never replaced. studio read the same lines independently. The + // hypothesis is the obvious one and the source does not support it. + // + // So rather than switch channels on a mechanism nobody can point at, the gate now measures BOTH: + // the host global that partners use, and the ?tk= URL rung. If both construct, the earlier + // failure was this gate's and is gone. If ONLY ?tk= constructs, that is a PRODUCT defect on the + // host-global rung — the one an embedding partner reaches for — and the gate will say so by + // name instead of leaving a one-line change to look like a fix. + // + // The maplibre case carries NO token by either route: resolveMapProvider's `auto` rung reads a + // token as "use Mapbox", and a present token is exactly the variable this gate holds at zero. + const CASES = [{ id: 'maplibre', provider: 'maplibre', rung: 'none' }] + if (TOKEN) CASES.push( + { id: 'mapbox/window.MAP_TOKEN', provider: 'mapbox', rung: 'global' }, + { id: 'mapbox/?tk=', provider: 'mapbox', rung: 'url' }, + ) + for (const c of CASES) { + const engine = c.provider const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) const mapboxCalls = [] + // ★ STATUSES TOO, not just counts. "21 requests and no map" is ambiguous between a token that + // was never seen, one that was REJECTED, and a map that failed for an unrelated reason — and + // the three have different owners. A 401 says the key, not the code. + const mapboxBad = [] page.on('request', (r) => { const u = r.url(); if (u.includes('api.mapbox.com')) mapboxCalls.push(u) }) - // ★ ONLY under mapbox. Injecting a token into the maplibre run would change what is being - // measured: resolveMapProvider's `auto` rung reads a token as "use Mapbox", and even with an - // explicit ?provider=maplibre a present token is a variable this gate exists to hold at zero. - if (engine === 'mapbox') await page.addInitScript((tk) => { window.MAP_TOKEN = tk }, TOKEN) - await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 }) + page.on('response', (r) => { if (r.url().includes('api.mapbox.com') && r.status() >= 400) mapboxBad.push(r.status()) }) + if (c.rung === 'global') await page.addInitScript((tk) => { window.MAP_TOKEN = tk }, TOKEN) + // ★ ASK THE ENGINE, NOT THE DOM, WHETHER A MAP EXISTS. See the header note; borrowed from + // rega's tokrung.mjs, which measured this leaf correctly while this gate did not. Both globals + // are hooked because the leaf loads only the engine its provider asks for. + await page.addInitScript(() => { + window.__mapsBuilt = { mapbox: 0, maplibre: 0 } + const hook = (name) => { + let real + Object.defineProperty(window, name, { + configurable: true, + get: () => real, + set: (gl) => { + real = gl + try { + const Ctor = gl && gl.Map + if (Ctor && !Ctor.__counted) { + Ctor.__counted = true + const setStyle = Ctor.prototype.setStyle + Ctor.prototype.setStyle = function (...a) { + window.__mapsBuilt[name === 'mapboxgl' ? 'mapbox' : 'maplibre']++ + return setStyle.apply(this, a) + } + } + } catch (_) { /* a hook must never be the reason a page fails */ } + }, + }) + } + hook('mapboxgl'); hook('maplibregl') + }) + const url = `${LEAF}?provider=${engine}` + (c.rung === 'url' ? `&tk=${encodeURIComponent(TOKEN)}` : '') + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) await page.waitForTimeout(20000) const search = await page.evaluate(async () => { @@ -137,14 +210,26 @@ try { if (!root || seen.has(root)) return seen.add(root) for (const el of root.querySelectorAll('*')) { - if (el.matches && el.matches('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')) hits.push(el) + // ★ WIDER THAN THE PLUGIN'S OWN CLASS. This matched only + // input.mapboxgl-ctrl-geocoder--input and reported 0 bars on a leaf carrying three + // visible search inputs. Whether the miss was the class or the shadow walk cannot be told + // from here, and that is the point: a selector narrow enough that one wrong assumption + // yields ZERO accuses the product of being broken when it is only wrong about a name. + if (el.matches && el.matches( + 'input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input,' + + ' .mapboxgl-ctrl-geocoder input, .maplibregl-ctrl-geocoder input,' + + ' input[type="search"], input[placeholder*="Search" i]')) hits.push(el) if (el.shadowRoot) walk(el.shadowRoot) } } // Did an engine actually construct a map? A canvas is the one artefact both engines share, // and it tells "the bar was removed" apart from "there was never a map to hang it on". - let mapReady = false - for (const root of seen) { if (root.querySelector && root.querySelector('canvas.mapboxgl-canvas, canvas.maplibregl-canvas, .mapboxgl-map canvas, .maplibregl-map canvas')) { mapReady = true; break } } + // The engine hook is the answer; the canvas walk stays only as a second opinion, so an engine + // that stops routing through setStyle degrades to the old behaviour instead of accusing the + // product. + const built = window.__mapsBuilt || {} + let mapReady = !!(built.mapbox || built.maplibre) + if (!mapReady) for (const root of seen) { if (root.querySelector && root.querySelector('canvas.mapboxgl-canvas, canvas.maplibregl-canvas, .mapboxgl-map canvas, .maplibregl-map canvas')) { mapReady = true; break } } if (!hits.length) return { bars: 0, suggestions: 0, mapReady } const input = hits[0] input.focus() @@ -160,13 +245,14 @@ try { }) await page.close() - lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}${engine === 'mapbox' ? ' · token: supplied' : ''}`) + const bad = mapboxBad.length ? ` · REJECTED responses: ${mapboxBad.length} (${[...new Set(mapboxBad)].join(', ')})` : '' + lines.push(` · ${c.id.padEnd(22)} api.mapbox.com requests: ${mapboxCalls.length} · map: ${search.mapReady ? 'yes' : 'NO'} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}${bad}`) if (engine === 'maplibre') { // THE HEADLINE. One request sends the visitor's IP to Mapbox and breaks the keyless promise, // whatever the page looks like afterwards. if (mapboxCalls.length) { - fails.push(`under maplibre the leaf made ${mapboxCalls.length} request(s) to api.mapbox.com:`) + fails.push(`[${c.id}] the leaf made ${mapboxCalls.length} request(s) to api.mapbox.com:`) for (const u of [...new Set(mapboxCalls)].slice(0, 8)) fails.push(` ${u.slice(0, 150)}`) } // A bar is OPTIONAL here and its absence is the documented contract (see the header). What is @@ -182,11 +268,16 @@ try { // `mapReady` separates "the bar was removed" from "the map never constructed", which are // different bugs with the same symptom — and the second one is how this gate misread itself. if (!search.bars) { - fails.push(search.mapReady - ? 'under mapbox the map constructed but rendered NO search bar — hasGeocoder or the GeocoderComponent mount is broken.' - : 'under mapbox NO map constructed (so the bar\'s v-if could never fire). The token was supplied, so this is the product or the leaf, not a missing key.') + const rung = c.rung === 'global' ? 'the HOST GLOBAL rung (window.MAP_TOKEN, what an embedding partner sets)' : 'the ?tk= URL rung' + if (!search.mapReady) { + fails.push(mapboxBad.length + ? `[${c.id}] NO map constructed and ${mapboxBad.length} api.mapbox.com response(s) came back ${[...new Set(mapboxBad)].join('/')} — the token reached Mapbox and was REFUSED. That is the key, not the code.` + : `[${c.id}] NO map constructed, via ${rung}, with a token supplied and no rejected responses. The leaf never overwrites a host-set window.MAP_TOKEN (index.html:70 reads it, :71/:109/:150 are all guarded), so if the other rung constructs and this one does not, this is a PRODUCT defect on the rung partners use — not this gate.`) + } else { + fails.push(`[${c.id}] the map constructed but rendered NO search bar — hasGeocoder or the GeocoderComponent mount is broken.`) + } } else if (!search.suggestions) { - fails.push('under mapbox the search bar rendered but typing produced no suggestions.') + fails.push(`[${c.id}] the search bar rendered but typing produced no suggestions.`) } } } @@ -210,4 +301,4 @@ if (!TOKEN) { console.log(' Set MAPBOX_TOKEN in the environment, or `token` in vite.doxa-maps-build-config.json, to measure it.') process.exit(0) } -console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times, and Mapbox still searches.`) +console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times, and Mapbox searches on both token rungs.`)