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 67b15a393..814497c31 100644 --- a/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs +++ b/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs @@ -25,13 +25,45 @@ * look identical: the bars appear, suggestions work, nothing errors. Only the request log tells the * truth, which is why this counts requests rather than looking at the page. * - * ASSERTIONS, on the built tree: - * maplibre ZERO requests to api.mapbox.com. - * both whatever search bars this leaf renders still produce suggestions — a zero bought by - * breaking search is not a fix. A leaf with NO bar now FAILS (it used to pass hollow); - * the default leaf is doxa-research-map/research-map, which mounts one (--leaf to change). + * ★ CORRECTED 2026-09-26, after rega reported "0 search bars on both engines" and it was the CHECK + * that was wrong, not the product. Two separate mistakes, both of them mine: * - * Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail. + * 1. UNDER MAPLIBRE, NO SEARCH BAR IS THE CONTRACT — not a hollow pass and not a failure. + * research-map.vue: `hasGeocoder = mapProvider === 'mapbox'`, and research-map.instances.json + * says it in words: "Under maplibre the search bar is deliberately ABSENT — GeocoderComponent's + * maplibre path still geocodes through Mapbox, so shipping it would bill the very key this + * instance exists to avoid". The previous revision had just been taught that a bar-less leaf is + * a hollow pass, and applied that lesson to the one engine where ABSENCE IS THE POINT. An + * absent bar is in fact the STRONGEST form of this gate's own assertion: a search that cannot + * be typed into cannot send anyone's IP to Mapbox. + * So the rule is now conditional rather than hardcoded, because a keyless geocoder may ship + * one day and this gate should not have to be rewritten when it does: if maplibre renders NO + * bar, that is recorded and passes; if it renders one, it must WORK and still make zero + * Mapbox requests. Either way the zero is what is asserted. + * + * 2. THE MAPBOX RUN WAS NEVER GIVEN A TOKEN, so it could not have rendered a bar either. The leaf + * bakes BAKED_TOKEN = "" and nothing else supplies one, so mapbox-gl cannot construct a Map, + * `map` stays null, and the bar's `v-if="appReady && map && hasGeocoder"` never fires. Note the + * mechanism: an explicit ?provider=mapbox does NOT fall back to maplibre (explicitMapProvider + * 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 + * 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. + * + * 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. + * + * NEVER PRINTS THE TOKEN. Only its presence is reported. + * + * Exit: 0 pass or SKIP (no built tree, no browser, no data, no token) · 1 fail. */ import { createServer } from 'node:http' import { spawn } from 'node:child_process' @@ -50,6 +82,12 @@ let chromium try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') } const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) +// The token ladder every other browser gate here uses: env first, then the build config. Presence +// is reported; the value never is. +const TOKEN = (() => { + if (process.env.MAPBOX_TOKEN) return process.env.MAPBOX_TOKEN + try { return JSON.parse(readFileSync(join(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')).token || '' } catch { return '' } +})() // ★ A LEAF THAT MOUNTS THE SEARCH BAR, or the suggestion half proves nothing (2026-09-26: the // research-dashboard leaf renders no bar, so every run passed with "search bars: 0" — a hollow PASS). // Default: the research map, which mounts the library GeocoderComponent. --leaf /. @@ -78,10 +116,17 @@ const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '- const fails = [] const lines = [] try { - for (const engine of ['maplibre', 'mapbox']) { + // 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) { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) const mapboxCalls = [] 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 }) await page.waitForTimeout(20000) @@ -96,8 +141,11 @@ try { if (el.shadowRoot) walk(el.shadowRoot) } } - walk(document) - if (!hits.length) return { bars: 0, suggestions: 0 } + // 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 } } + if (!hits.length) return { bars: 0, suggestions: 0, mapReady } const input = hits[0] input.focus() input.value = 'Afar' @@ -106,23 +154,40 @@ try { await sleep(500) const root = input.getRootNode() const n = root.querySelectorAll('.mapboxgl-ctrl-geocoder .suggestions li, .maplibregl-ctrl-geocoder .suggestions li, .suggestions li').length - if (n) return { bars: hits.length, suggestions: n } + if (n) return { bars: hits.length, suggestions: n, mapReady } } - return { bars: hits.length, suggestions: 0 } + return { bars: hits.length, suggestions: 0, mapReady } }) await page.close() - lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}`) + lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}${engine === 'mapbox' ? ' · token: supplied' : ''}`) - if (engine === 'maplibre' && mapboxCalls.length) { - fails.push(`under maplibre the dashboard 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)}`) - } - // NO BAR IS A FAILURE, not a pass: this gate exists to prove search still works with zero Mapbox calls. - if (!search.bars) { - fails.push(`under ${engine} the leaf rendered NO search bar, so search was never exercised — point --leaf at a leaf that mounts one.`) - } else if (!search.suggestions) { - fails.push(`under ${engine} the search bar rendered but typing produced no suggestions — zero Mapbox traffic bought by a broken search is not a fix.`) + 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:`) + 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 + // NOT optional is that a bar, if present, works — otherwise a future keyless geocoder could + // ship broken and this gate would read it as a clean zero. + if (!search.bars) { + lines.push(' (no search bar under maplibre — the documented contract: research-map.vue hasGeocoder = provider === \'mapbox\', because GeocoderComponent\'s maplibre path still geocodes through Mapbox. An absent bar is the strongest form of the zero above.)') + } else if (!search.suggestions) { + fails.push('under maplibre a search bar rendered but typing produced no suggestions — if a keyless geocoder now ships it has to work, not merely be quiet.') + } + } else { + // MAPBOX: the bar is the feature this profile was built around, and it must still work. + // `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.') + } else if (!search.suggestions) { + fails.push('under mapbox the search bar rendered but typing produced no suggestions.') + } } } } catch (err) { @@ -138,4 +203,11 @@ if (fails.length) { for (const f of fails) console.log(` ✗ ${f}`) process.exit(1) } -console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times and still searches; Mapbox is unchanged.`) +if (!TOKEN) { + // Half-measured is not a pass. run-parallel reads a `TAG: SKIP` line and prints ⊘, so the wave + // cannot report this as one of N gates that graded something. Exit 0: nothing FAILED. + console.log(`${TAG}: SKIP — maplibre measured and clean; the mapbox half needs a key and none was supplied.`) + 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.`)