diff --git a/internal/check-facet-changes-map.mjs b/internal/check-facet-changes-map.mjs index 253c1059..91cd589e 100644 --- a/internal/check-facet-changes-map.mjs +++ b/internal/check-facet-changes-map.mjs @@ -47,6 +47,8 @@ * BASE_URL=http://127.0.0.1:4173 PROBE_PATH=//... node internal/check-facet-changes-map.mjs */ import { chromium } from './lib/playwright-guarded.mjs' +// The in-page helper lives in a browser-free module so check:facet-shape can test the same string headlessly. +import { WALK } from './lib/facet-walk.mjs' // Defaults to the DEV server, because this gate drives a live interaction and the dev surface is // the one that always reflects current source. Point BASE_URL/PROBE_PATH at the built tree @@ -110,96 +112,6 @@ const GL_ARGS = [ '--no-sandbox', ] -// Shadow DOM DOES NOT PIERCE — recursive walk with a seen-Set, re-injected after every interaction. -const WALK = ` -function __walk(sel) { - const seen = new Set(); const out = []; - const visit = (root) => { - if (!root || seen.has(root)) return; seen.add(root); - let els = []; try { els = Array.from(root.querySelectorAll('*')) } catch (e) { return } - for (const el of els) { - try { if (el.matches && el.matches(sel)) out.push(el) } catch (e) {} - if (el.shadowRoot) visit(el.shadowRoot); - } - }; - visit(document); return out; -} -// THE MEASUREMENT: the map's own bubble source — the DOTS the agent looks at — plus the paint -// property that decides what each one LOOKS like. -// ★ THE MAP HANDLE CAN GO STALE. The panel destroys and re-creates its Map on a re-render (and -// the dev server's HMR does this freely), which leaves window.__rdashMap pointing at a torn-down -// instance whose getSource() throws "Cannot read properties of undefined (reading 'getOwnSource')". -// Measured: one run in three failed that way on the dev surface while the product was fine. -// A gate that reports a product bug when its own handle went stale is a gate that gets ignored, so -// __live() proves the handle still works before any assertion reads through it. -// ★ READ A GEOJSON SOURCE'S FEATURES WITHOUT ASSUMING ONE ENGINE'S PRIVATE SHAPE. -// This gate could only ever measure Mapbox (BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE), and the cause -// is four reads of 'source._data'. The two engines store it differently — cited from the bundles -// this repo actually loads, same constructor in each: -// mapbox-gl v3.24.0 this._data = i.data (the raw GeoJSON) -// maplibre-gl this._data = "string"==typeof i.data ? {url:…} : {geojson: i.data} -// So '_data.features' is undefined under MapLibre and every read fell to an empty list. A GATE that -// cannot see MapLibre's features PASSES A MAP THAT IS BROKEN THERE, which is worse than a plugin -// having the same bug. -// -// ⚠ THE 's._data || s.serialize().data' FALLBACK DOES NOT FIX IT, and that is why this is a helper -// rather than a copied one-liner: '||' only fires when the left side is FALSY, and '{geojson: …}' -// is TRUTHY. The old __state() carried that form and was broken under MapLibre exactly like the -// three unguarded reads. The wrapper has to be UNWRAPPED, not fallen back from. -// -// ⚠ AND IT LIVES HERE, IN THE PAGE, not as an import of library/maps/pin-selection.js. All four -// call sites run inside page.evaluate / page.waitForFunction bodies, which are serialised to the -// browser — a Node-side import does not exist there. This is the same logic as readSourceFeatures(), -// deliberately duplicated across the process boundary, which is the one place duplication is not a -// smell. If that helper's rules change, this must follow. -function __srcFeatures(m, id) { - let s; try { s = m.getSource(id) } catch (e) { return null } - if (!s) return null; - let data = s._data !== undefined ? s._data : (s.serialize && s.serialize().data); - if (data && typeof data === 'object' && !Array.isArray(data)) { - if (data.geojson !== undefined) data = data.geojson; - // A REMOTE SOURCE IS NOT AN EMPTY ONE. Its features live on a server and cannot be enumerated - // here; returning [] would read as "the map drew nothing", which is the bug this gate exists to - // catch. null means "could not measure", and the caller must not treat it as zero. - else if (data.url !== undefined) return null; - } - return (data && data.features) || null; -} -function __live() { - const m = window.__rdashMap; - if (!m) return null; - try { m.getSource('rd-bubbles'); return m } catch (e) { return null } -} -function __state() { - const m = __live(); - if (!m) return { stale: true, err: 'map handle is stale (torn down and re-created)' }; - let s; try { s = m.getSource('rd-bubbles') } catch (e) { return { stale: true, err: 'map torn down: ' + e.message } } - if (!s) return { err: 'no rd-bubbles source' }; - const feats = __srcFeatures(m, 'rd-bubbles') || []; - let color = null; try { color = m.getPaintProperty('rd-bubble-layer', 'circle-color') } catch (e) {} - return { - n: feats.length, - slugs: feats.map((f) => f.properties && f.properties.slug), - praying: feats.map((f) => (f.properties ? f.properties.peoplePraying : undefined)), - color, - colorIsExpression: Array.isArray(color), - colorJson: JSON.stringify(color), - }; -} -function __openPrayer() { - const dd = __walk('.filter-dropdown').find((d) => (d.textContent || '').includes('Prayer')); - if (!dd) return false; - const b = dd.querySelector('button'); if (!b) return false; - b.click(); return true; -} -function __pickPrayer(label) { - const dd = __walk('.filter-dropdown').find((d) => (d.textContent || '').includes('Prayer')); - if (!dd) return 'no dropdown'; - const o = Array.from(dd.querySelectorAll('[role=option]')).find((x) => (x.textContent || '').trim().startsWith(label)); - if (!o) return 'no option: ' + label; - o.click(); return 'ok'; -} -` // ★ THE SELF-TEST INVERTS THE EXIT CODE, and it must do so HERE rather than at the end. // Every assertion below calls fail() and exits immediately, so a self-test run that correctly @@ -225,40 +137,6 @@ const LEVELS = [ { label: 'Needs Prayer', holds: (n) => !(Number(n) > 0) }, ] -// ── SHAPE TEST (headless, no browser) ──────────────────────────────────────────────────────────── -// ★ NEGATIVE-TESTS __srcFeatures AGAINST BOTH ENGINE SHAPES, in sr's check-pin-selection style. -// ⚠ NOT `--self-test`: that already means "run the browser gate with the bug put back, it MUST -// fail". This one never opens a browser, so it can run anywhere and in gates:pre. -// It evaluates the helper OUT OF THE WALK STRING ITSELF, so the thing tested is the thing injected -// into the page — a copy pasted here would pass while the shipped helper rotted. -if (process.argv.includes('--shape-test')) { - const FC = { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { slug: 'a' } }, - { type: 'Feature', properties: { slug: 'b' } }] } - const srcFeatures = new Function(`${WALK}\nreturn __srcFeatures`)() - const mapWith = (src) => ({ getSource: (id) => (id === 'rd-bubbles' ? src : null) }) - const cases = [ - ['mapbox shape (_data = FeatureCollection)', () => srcFeatures(mapWith({ _data: FC }), 'rd-bubbles'), 2], - ['maplibre shape (_data = { geojson })', () => srcFeatures(mapWith({ _data: { geojson: FC } }), 'rd-bubbles'), 2], - ['public fallback (no _data, serialize())', () => srcFeatures(mapWith({ serialize: () => ({ data: FC }) }), 'rd-bubbles'), 2], - // A REMOTE SOURCE MUST NOT READ AS EMPTY. null means "could not measure"; 0 would mean "the map - // drew nothing", which is the finding this gate exists to report. - ['remote (_data = { url }) -> null, not 0', () => srcFeatures(mapWith({ _data: { url: 'https://x.invalid/a.json' } }), 'rd-bubbles'), null], - ['absent source -> null', () => srcFeatures(mapWith(null), 'rd-bubbles'), null], - ] - let ok = 0 - for (const [name, run, want] of cases) { - const got = run() - const n = got === null || got === undefined ? null : got.length - const pass = n === want - console.log(` ${pass ? '✓' : '✗'} ${name} -> ${n === null ? 'null' : n + ' feature(s)'}`) - if (pass) ok++ - } - console.log(ok === cases.length - ? `FACET-SHAPE-TEST: PASS — ${ok}/${cases.length}, both engine shapes measured` - : `FACET-SHAPE-TEST: FAIL — ${ok}/${cases.length}`) - process.exit(ok === cases.length ? 0 : 1) -} - async function reinject(page) { await page.addScriptTag({ content: WALK }) } // Read __state(), tolerating a STALE map handle: if the panel re-created its Map mid-interaction diff --git a/internal/check-facet-shape.mjs b/internal/check-facet-shape.mjs new file mode 100644 index 00000000..8df2d299 --- /dev/null +++ b/internal/check-facet-shape.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/** + * check-facet-shape — the facet gate's in-page source reader, tested headlessly on both engines' shapes. + * Imports the SAME WALK string check:facet-changes-map injects (internal/lib/facet-walk.mjs), so a copy cannot + * drift from what ships. No browser. Negative-tested by upg100: without the { geojson } unwrap it is 4/5. + * EXIT 0 pass · 1 fail + */ +import { WALK } from './lib/facet-walk.mjs' + +// ── SHAPE TEST (headless, no browser) ──────────────────────────────────────────────────────────── +// ★ NEGATIVE-TESTS __srcFeatures AGAINST BOTH ENGINE SHAPES, in sr's check-pin-selection style. +// ⚠ NOT `--self-test`: that already means "run the browser gate with the bug put back, it MUST +// fail". This one never opens a browser, so it can run anywhere and in gates:pre. +// It evaluates the helper OUT OF THE WALK STRING ITSELF, so the thing tested is the thing injected +// into the page — a copy pasted here would pass while the shipped helper rotted. +const FC = { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { slug: 'a' } }, + { type: 'Feature', properties: { slug: 'b' } }] } +const srcFeatures = new Function(`${WALK}\nreturn __srcFeatures`)() +const mapWith = (src) => ({ getSource: (id) => (id === 'rd-bubbles' ? src : null) }) +const cases = [ + ['mapbox shape (_data = FeatureCollection)', () => srcFeatures(mapWith({ _data: FC }), 'rd-bubbles'), 2], + ['maplibre shape (_data = { geojson })', () => srcFeatures(mapWith({ _data: { geojson: FC } }), 'rd-bubbles'), 2], + ['public fallback (no _data, serialize())', () => srcFeatures(mapWith({ serialize: () => ({ data: FC }) }), 'rd-bubbles'), 2], + // A REMOTE SOURCE MUST NOT READ AS EMPTY. null means "could not measure"; 0 would mean "the map + // drew nothing", which is the finding this gate exists to report. + ['remote (_data = { url }) -> null, not 0', () => srcFeatures(mapWith({ _data: { url: 'https://x.invalid/a.json' } }), 'rd-bubbles'), null], + ['absent source -> null', () => srcFeatures(mapWith(null), 'rd-bubbles'), null], +] +let ok = 0 +for (const [name, run, want] of cases) { + const got = run() + const n = got === null || got === undefined ? null : got.length + const pass = n === want + console.log(` ${pass ? '✓' : '✗'} ${name} -> ${n === null ? 'null' : n + ' feature(s)'}`) + if (pass) ok++ +} +console.log(ok === cases.length + ? `FACET-SHAPE-TEST: PASS — ${ok}/${cases.length}, both engine shapes measured` + : `FACET-SHAPE-TEST: FAIL — ${ok}/${cases.length}`) +process.exit(ok === cases.length ? 0 : 1) diff --git a/internal/lib/facet-walk.mjs b/internal/lib/facet-walk.mjs new file mode 100644 index 00000000..ce6cbd1d --- /dev/null +++ b/internal/lib/facet-walk.mjs @@ -0,0 +1,96 @@ +/** + * internal/lib/facet-walk.mjs — the in-page helper string check:facet-changes-map injects into the page + * (__walk through open shadow roots, __srcFeatures reading a GeoJSON source in BOTH engines' shapes). + * Browser-free on purpose: check:facet-shape imports this SAME string and evaluates it headlessly, so the + * thing tested is the thing injected, and the test runs in gates:pre on any seat. + */ +// Shadow DOM DOES NOT PIERCE — recursive walk with a seen-Set, re-injected after every interaction. +export const WALK = ` +function __walk(sel) { + const seen = new Set(); const out = []; + const visit = (root) => { + if (!root || seen.has(root)) return; seen.add(root); + let els = []; try { els = Array.from(root.querySelectorAll('*')) } catch (e) { return } + for (const el of els) { + try { if (el.matches && el.matches(sel)) out.push(el) } catch (e) {} + if (el.shadowRoot) visit(el.shadowRoot); + } + }; + visit(document); return out; +} +// THE MEASUREMENT: the map's own bubble source — the DOTS the agent looks at — plus the paint +// property that decides what each one LOOKS like. +// ★ THE MAP HANDLE CAN GO STALE. The panel destroys and re-creates its Map on a re-render (and +// the dev server's HMR does this freely), which leaves window.__rdashMap pointing at a torn-down +// instance whose getSource() throws "Cannot read properties of undefined (reading 'getOwnSource')". +// Measured: one run in three failed that way on the dev surface while the product was fine. +// A gate that reports a product bug when its own handle went stale is a gate that gets ignored, so +// __live() proves the handle still works before any assertion reads through it. +// ★ READ A GEOJSON SOURCE'S FEATURES WITHOUT ASSUMING ONE ENGINE'S PRIVATE SHAPE. +// This gate could only ever measure Mapbox (BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE), and the cause +// is four reads of 'source._data'. The two engines store it differently — cited from the bundles +// this repo actually loads, same constructor in each: +// mapbox-gl v3.24.0 this._data = i.data (the raw GeoJSON) +// maplibre-gl this._data = "string"==typeof i.data ? {url:…} : {geojson: i.data} +// So '_data.features' is undefined under MapLibre and every read fell to an empty list. A GATE that +// cannot see MapLibre's features PASSES A MAP THAT IS BROKEN THERE, which is worse than a plugin +// having the same bug. +// +// ⚠ THE 's._data || s.serialize().data' FALLBACK DOES NOT FIX IT, and that is why this is a helper +// rather than a copied one-liner: '||' only fires when the left side is FALSY, and '{geojson: …}' +// is TRUTHY. The old __state() carried that form and was broken under MapLibre exactly like the +// three unguarded reads. The wrapper has to be UNWRAPPED, not fallen back from. +// +// ⚠ AND IT LIVES HERE, IN THE PAGE, not as an import of library/maps/pin-selection.js. All four +// call sites run inside page.evaluate / page.waitForFunction bodies, which are serialised to the +// browser — a Node-side import does not exist there. This is the same logic as readSourceFeatures(), +// deliberately duplicated across the process boundary, which is the one place duplication is not a +// smell. If that helper's rules change, this must follow. +function __srcFeatures(m, id) { + let s; try { s = m.getSource(id) } catch (e) { return null } + if (!s) return null; + let data = s._data !== undefined ? s._data : (s.serialize && s.serialize().data); + if (data && typeof data === 'object' && !Array.isArray(data)) { + if (data.geojson !== undefined) data = data.geojson; + // A REMOTE SOURCE IS NOT AN EMPTY ONE. Its features live on a server and cannot be enumerated + // here; returning [] would read as "the map drew nothing", which is the bug this gate exists to + // catch. null means "could not measure", and the caller must not treat it as zero. + else if (data.url !== undefined) return null; + } + return (data && data.features) || null; +} +function __live() { + const m = window.__rdashMap; + if (!m) return null; + try { m.getSource('rd-bubbles'); return m } catch (e) { return null } +} +function __state() { + const m = __live(); + if (!m) return { stale: true, err: 'map handle is stale (torn down and re-created)' }; + let s; try { s = m.getSource('rd-bubbles') } catch (e) { return { stale: true, err: 'map torn down: ' + e.message } } + if (!s) return { err: 'no rd-bubbles source' }; + const feats = __srcFeatures(m, 'rd-bubbles') || []; + let color = null; try { color = m.getPaintProperty('rd-bubble-layer', 'circle-color') } catch (e) {} + return { + n: feats.length, + slugs: feats.map((f) => f.properties && f.properties.slug), + praying: feats.map((f) => (f.properties ? f.properties.peoplePraying : undefined)), + color, + colorIsExpression: Array.isArray(color), + colorJson: JSON.stringify(color), + }; +} +function __openPrayer() { + const dd = __walk('.filter-dropdown').find((d) => (d.textContent || '').includes('Prayer')); + if (!dd) return false; + const b = dd.querySelector('button'); if (!b) return false; + b.click(); return true; +} +function __pickPrayer(label) { + const dd = __walk('.filter-dropdown').find((d) => (d.textContent || '').includes('Prayer')); + if (!dd) return 'no dropdown'; + const o = Array.from(dd.querySelectorAll('[role=option]')).find((x) => (x.textContent || '').trim().startsWith(label)); + if (!o) return 'no option: ' + label; + o.click(); return 'ok'; +} +` diff --git a/package.json b/package.json index c9fd71d0..51bc0422 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "check:facet-cascade": "node internal/check-facet-cascade.mjs", "check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline", "check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test", - "check:facet-shape": "node internal/check-facet-changes-map.mjs --shape-test", + "check:facet-shape": "node internal/check-facet-shape.mjs", "check:facet-changes-map": "node internal/check-facet-changes-map.mjs", "check:facet-changes-map:built": "sh -c 'BASE_URL=${BASE_URL:-http://127.0.0.1:4173} PROBE_PATH=${PROBE_PATH:-/doxa-maps/research-dashboard/research-dashboard/index.html} exec node internal/check-facet-changes-map.mjs'", "check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test", @@ -259,7 +259,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", diff --git a/plugin-designs/big-picture/big-picture.vue b/plugin-designs/big-picture/big-picture.vue index feacba87..6c4e3f4a 100644 --- a/plugin-designs/big-picture/big-picture.vue +++ b/plugin-designs/big-picture/big-picture.vue @@ -126,6 +126,12 @@ watch(textDir, reflectDir) const listConfig = computed(() => { const cfg = {} if (config.value.list) cfg.list = config.value.list + // ⚠ THE SPELLING IS LOAD-BEARING AND THE TWO SIDES DO NOT MATCH BY NAME. This writes `lang`; the + // grid's prop is `languageCode`. It works because people-groups-grid.vue accepts three spellings — + // languageCode || locale || lang — and `lang` is the THIRD of them. Rename or "tidy" either side + // without the other and a composed grid silently renders in the wrong language on a profile-config + // embed, which is the exact failure 8b40f6c58 was written to fix and which no gate would catch: + // ?lang= embeds keep working, so only a host passing profile-config sees it. if (hostLang.value) cfg.lang = hostLang.value return Object.keys(cfg).length ? JSON.stringify(cfg) : '' }) diff --git a/plugin-designs/gospel-internet/board.vue b/plugin-designs/gospel-internet/board.vue index 2f1ca27d..fe95b258 100644 --- a/plugin-designs/gospel-internet/board.vue +++ b/plugin-designs/gospel-internet/board.vue @@ -120,7 +120,11 @@ const MOBILE_TABS = computed(() => [ // The centre shows the geographic map or the language tree; the tree (its own whiteboard map) is built // on first use and then kept, so switching back and forth costs nothing after the first time. const treeEver = ref(false) -watch(() => board.centre.value, (c) => { if (c === 'tree') treeEver.value = true }, { immediate: true }) +// The geographic map mounts the same way, on first show: the board opens on the tree, and a hidden map +// still built a second WebGL context, its tiles and 1,700 pins on every open (it froze /embed-test when +// several heavy cards mounted together). Once shown it stays mounted, so switching back is instant. +const mapEver = ref(false) +watch(() => board.centre.value, (c) => { if (c === 'tree') treeEver.value = true; if (c === 'map') mapEver.value = true }, { immediate: true }) const CENTRE_TABS = computed(() => [ { id: 'map', label: T('gospelInternet.centre.map', 'Map') }, { id: 'tree', label: T('gospelInternet.centre.tree', 'Language tree') }, @@ -241,7 +245,7 @@ useShadowStyles(
- +