doxa-maps/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs
Doxa Maps d3967fe37a
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 2ff0ecba
2026-09-25 23:12:08 +00:00

134 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
/**
* check-dashboard-geocoder-provider.mjs — a dashboard running on MapLibre must not call Mapbox.
*
* THE CONTRACT: a partner who selects MapLibre has selected the keyless engine precisely so that no
* Mapbox key and no Mapbox traffic is involved. One request to api.mapbox.com sends that visitor's
* IP to Mapbox and breaks the promise, whatever the page looks like afterwards.
*
* ★ WHAT THIS MEASURED, AND IT IS NOT WHERE THE AUDIT POINTED. studio's Mapbox-globals audit named
* DashboardSearchHeader.vue, which did preload api.mapbox.com/.../mapbox-gl-geocoder.min.js
* unconditionally and never passed `provider` to the GeocoderComponent it mounts — both now fixed.
* But that component is NOT MOUNTED by this dashboard (research-dashboard.vue:23 says so in as many
* words), so fixing it changes nothing here. The four requests this leaf actually makes under
* ?provider=maplibre are all from the GENERATED LEAF PAGE's own tags:
*
* mapbox-gl.css · mapbox-gl.js · mapbox-gl-geocoder.css · mapbox-gl-geocoder.min.js
*
* i.e. the leaf ships Mapbox GL and the Mapbox geocoder to every visitor regardless of the engine
* it was asked for. That is the leaf GENERATOR's doing, not this plugin's, and it is why this gate
* is deliberately NOT wired into gates:post yet: it would be a red gate about somebody else's file.
* Run it by name; it names the exact URLs.
*
* ★ ZERO IS THE ASSERTION, not "it rendered". A build that quietly kept the Mapbox geocoder would
* 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. MEASURED: this leaf renders NONE, because the search
* header is not mounted here, so that half reports rather than asserts. Point the gate
* at a leaf that mounts the bars and it starts asserting again.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'DASHBOARD-GEOCODER-PROVIDER'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-dashboard')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
if (!profile) skip('the manifest declares no research-dashboard leaf to load.')
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
cleanup(); skip('could not serve the built tree locally.')
}
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
const lines = []
try {
for (const engine of ['maplibre', 'mapbox']) {
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) })
await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
await page.waitForTimeout(20000)
const search = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const seen = new Set(); const hits = []
const walk = (root) => {
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)
if (el.shadowRoot) walk(el.shadowRoot)
}
}
walk(document)
if (!hits.length) return { bars: 0, suggestions: 0 }
const input = hits[0]
input.focus()
input.value = 'Afar'
input.dispatchEvent(new Event('input', { bubbles: true }))
for (let i = 0; i < 40; i++) {
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 }
}
return { bars: hits.length, suggestions: 0 }
})
await page.close()
lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}`)
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)}`)
}
// Reported, not asserted, when the leaf mounts no bars at all — see the header note.
if (search.bars && !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.`)
}
}
} catch (err) {
fails.push(`the measurement itself failed: ${err && err.message}`)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const l of lines) console.log(l)
if (fails.length) {
console.log(`\n${TAG}: FAIL`)
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.`)