doxa-maps/plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs
Doxa Maps fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
2026-09-28 08:20:21 +00:00

172 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* check-pin-dim-slider.mjs — the side menu's pin-dim slider and the legend's dimming must COMPOSE,
* not compete, and the slider must actually reach the map.
*
* THE DEFECT. The slider wrote mapStore.clusterPinOpacity, which mapStore never declared, so
* dragging it changed a number nobody read — the same shape of defect as the clustering picker
* beside it, found while fixing that one. And the naive fix would have created a worse one: the pin
* layer's circle-opacity is ALSO owned by legend dimming (a ['case', …, 1, 0] expression), so a flat
* slider value written by a second owner would silently erase dimming, or be erased by it, depending
* on which the reader touched last.
*
* ★ SO THE VALUE IS A PRODUCT, AND THAT IS WHAT THIS ASSERTS: opacity = slider × (1 matched, 0
* dimmed). The slider scales the whole layer; dimming still dims relative to it; neither wins.
*
* ASSERTIONS, reading the LIVE paint property rather than the store:
* clustering off a plain 1 — the slider is hidden then, and dimming a map with nothing
* to dim FOR would be a regression of its own.
* clustering on, 0.5 a plain 0.5.
* + a dimming selection ['case', <expr>, 0.5, 0] — dimming survives the slider.
* slider back to 1.0 ['case', <expr>, 1, 0] — the slider survives dimming.
*
* 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 = 'PIN-DIM-SLIDER'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
// The tree's folder name is configurable and was renamed on 2026-09-25; reading it from the one
// helper is what keeps this gate pointed at the tree that exists. A hardcoded name does not error,
// it 404s — and a gate that 404s reports a clean page.
const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'doxa-research-map')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl && /research-map/.test(p.iframeUrl))
if (!profile) skip('the manifest declares no research-map 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 {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
await page.goto(LEAF, { waitUntil: 'domcontentloaded', timeout: 60000 })
const out = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let read = null
for (let i = 0; i < 120 && !read; i++) {
if (typeof window.__researchMapPinOpacity === 'function') read = window.__researchMapPinOpacity
else await sleep(500)
}
if (!read) return { skip: 'the map never exposed its pin-opacity report' }
for (let i = 0; i < 120; i++) {
if ((window.__researchMapVocabulary?.('religionFamily') || []).length) break
await sleep(500)
}
// Fresh walk each time: the slider is behind v-if="clusterMode !== 'off'", so it does not EXIST
// until a clustering mode is chosen. Caching the visited set across calls (as the clustering gate
// can) would mean searching a DOM snapshot taken before the element was created — which is how
// this gate first reported "the slider is not in the DOM" on a build where it works.
const findAll = (sel) => {
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(sel)) hits.push(el)
if (el.shadowRoot) walk(el.shadowRoot)
}
}
walk(document)
return hits
}
const buttons = findAll('.rm-cluster-btn')
if (buttons.length !== 4) return { skip: `expected 4 clustering buttons, found ${buttons.length}` }
const setSlider = (v) => {
const slider = findAll('#rm-pin-op')[0] || findAll('.rm-slider-row input[type=range]')[0]
if (!slider) return false
slider.value = String(v)
slider.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
const host = document.querySelector('doxa-research-map')
// ★ DIM BY RELIGION, NOT BY REGION, AND THAT IS NOT ARBITRARY. MEASURED while writing this: a
// REGION selection does not dim at all — it sets a layer FILTER, so non-matching pins are
// removed rather than faded, and the opacity stays flat. Only the case-expression dim paths
// (religion / prayer / engagement / adoption / language-family) compose with the slider, so
// testing region here would have reported "the slider erased the dimming" about a path that
// never writes opacity.
const dim = () => {
let cfg = {}
try { cfg = JSON.parse(host.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} }
cfg.externalFilters = { region: '', religion: 'Buddhism', language: '' }
host.setAttribute('profile-config', JSON.stringify(cfg))
}
const out = {}
buttons[0].click(); await sleep(4000) // Off
out.off = read()
buttons[2].click(); await sleep(8000) // MST — the slider only applies while clustering draws
if (!setSlider(0.5)) return { skip: 'the pin-dim slider never appeared after choosing a clustering mode' }
await sleep(2500)
out.clusteredHalf = read()
dim(); await sleep(9000)
out.dimmedHalf = read()
setSlider(1); await sleep(2500)
out.dimmedFull = read()
return out
})
if (out.skip) { await browser.close(); cleanup(); skip(out.skip) }
const show = (v) => JSON.stringify(v)
lines.push(` · clustering off ${show(out.off)}`)
lines.push(` · clustering on, 0.5 ${show(out.clusteredHalf)}`)
lines.push(` · + dimming, 0.5 ${show(out.dimmedHalf)}`)
lines.push(` · + dimming, 1.0 ${show(out.dimmedFull)}`)
const isCase = (v) => Array.isArray(v) && v[0] === 'case' && v.length === 4
if (out.off !== 1) fails.push(`with clustering off the pins should be fully opaque, got ${show(out.off)} — the slider is applying where it is not even shown.`)
if (out.clusteredHalf !== 0.5) fails.push(`the slider at 0.5 with clustering on should give a flat 0.5, got ${show(out.clusteredHalf)} — the slider is not reaching the map.`)
if (!isCase(out.dimmedHalf)) fails.push(`dimming while the slider is at 0.5 should give a ['case', …] expression, got ${show(out.dimmedHalf)} — the slider ERASED the dimming.`)
else {
if (out.dimmedHalf[2] !== 0.5) fails.push(`matched pins should render at the slider's 0.5 while dimming, got ${show(out.dimmedHalf[2])} — the dimming ERASED the slider.`)
if (out.dimmedHalf[3] !== 0) fails.push(`dimmed pins should render at 0, got ${show(out.dimmedHalf[3])}.`)
}
if (!isCase(out.dimmedFull)) fails.push(`returning the slider to 1.0 should keep the dimming expression, got ${show(out.dimmedFull)} — the slider erased it on the way back.`)
else if (out.dimmedFull[2] !== 1) fails.push(`at slider 1.0 matched pins should be fully opaque, got ${show(out.dimmedFull[2])}.`)
} 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}`)
console.log('\n opacity = slider × (1 matched, 0 dimmed). One writer, one product — see applyPinOpacity.')
process.exit(1)
}
console.log(`${TAG}: PASS — the slider scales the layer, dimming still dims relative to it, and neither erases the other.`)