library/maps/rtl-text.js installed the RTL text plugin for MAPBOX only (window.mapboxgl, from api.mapbox.com). So every MapLibre map, i.e. every keyless embed, drew RTL place names as disconnected, reversed glyphs. Measured on the Prayer Walk Planner's printed Arabic sheet. Under it was a second gap. Bundle entries call the installer at LOAD time, before useMapInstance has lazily injected a MapLibre engine, so even an engine-aware installer found nothing to install into. - rtl-text.js is engine-aware. Mapbox is unchanged (same URL, same lazy call). MapLibre gets the same plugin package from jsDelivr, keyless and not api.mapbox.com, so keyless pages still make zero Mapbox requests. New ensureRtlTextPlugin(gl) waits until it is LOADED, for callers that capture a single frame (print). - useMapInstance installs it the moment an engine global exists, the one point every map passes through. - The planner drops its private copy for the shared helper. Measured: a print map of downtown Cairo (MapLibre, keyless) shows shaped Arabic street and place names (شارع التحرير, مسجد الطباخ …); plugin status "loaded", fetched from jsDelivr. check:gallery-embed passes js, iframe, neighbour and linked (14 tabs); :maplibre passes; check:prayer-walk-planner passes. The shared change reaches all 11 bundles that compile initializeMap, and all are rebuilt here. check:shared-reached gains an rtl-text-maplibre marker (10/10 consumers carry it; cities-internet, gospel-resources and my-upg-100-list boot maps another way and are not affected). Also: the new Experimental bundle pplr-data-maps loads the Mapbox geocoder under maplibre (pplrData.js:38). It is an owned, printed MAPBOX_KNOWN entry routed to its owner; delete it in their fix commit.
356 lines
26 KiB
JavaScript
356 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-research-gallery-embed.mjs — the cap-5 Research Gallery (<doxa-maps-spa>) must embed on
|
|
* SOMEONE ELSE'S page, in both embed modes, with every nested map painting.
|
|
*
|
|
* WHAT THIS PROTECTS. The gallery is going onto the DX-life research page. Every other gate loads
|
|
* the gallery on its OWN generated leaf page, which bakes the three things a nested map needs —
|
|
* window.MAP_APP_API_URL, window.MAP_TOKEN and a correct assets path — and pins the tag
|
|
* position:fixed. A foreign page supplies none of that, so those gates cannot see the embed failing.
|
|
* Measured 2026-09-24 before the fix, from a second origin: the gallery drew OVER the host's header
|
|
* (the element had no :host box, so the layout's position:absolute root anchored to the viewport),
|
|
* and 8 of 13 tabs showed an error card because every data call hit the host's origin.
|
|
*
|
|
* HOW. Serves the built tree with internal/serve-strict.mjs (production parity: strict directories,
|
|
* ACAO:*) on 127.0.0.1, and a stand-in host page on `localhost` — a DIFFERENT ORIGIN, so CORS is
|
|
* genuinely exercised. The host page has its own header and footer around the embed. Then, per mode:
|
|
* js — `<script src=…/doxa-maps-spa.js>` + a bare `<doxa-maps-spa>` tag, NO attributes: the
|
|
* zero-config embed the docs promise.
|
|
* iframe — an <iframe> at the gallery's leaf page.
|
|
* and for every tab in the rail: select it, wait for it to mount, then assert.
|
|
*
|
|
* ASSERTIONS (a FAIL names the tab and the reason):
|
|
* 1. js mode: the layout root sits INSIDE the element's box (not at the viewport origin).
|
|
* 2. every tab reaches state 'ready' (never 'error', never stuck 'idle'/'loading').
|
|
* 3. every CURATED tab paints a WebGL canvas — they are all maps or map composites.
|
|
* Experimental tabs need only mount: some are cards or legacy frames with no canvas.
|
|
* 5. GOOD NEIGHBOUR (full runs only): embedded below the fold on a phone-width host page with hostile
|
|
* typography, the gallery must not scroll the host, rewrite its URL or title, or inherit its
|
|
* font metrics — after load and after every tab. Nested-plugin violations are listed, owned and
|
|
* printed in NEIGHBOUR_KNOWN until their owners fix them.
|
|
* 4. zero failed requests to the TREE's own origin (a 404/CORS on our assets is our bug; third-
|
|
* party tile/glyph hiccups are reported, not failed — they are not this repo's to fix).
|
|
*
|
|
* PRECONDITION: a built tree in public/doxa-maps (this starts its own servers; no dev server).
|
|
* Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id] [--provider maplibre]
|
|
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser) · 75 NOT MEASURED (server identity)
|
|
*/
|
|
import { createServer } from 'node:http'
|
|
import { spawn } from 'node:child_process'
|
|
import { existsSync } from 'node:fs'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const argv = process.argv.slice(2)
|
|
const argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
|
const MODES = argOf('--mode', '') ? [argOf('--mode', '')] : ['js', 'iframe']
|
|
const ONLY = argOf('--only', '') ? argOf('--only', '').split(',') : null
|
|
// --provider maplibre: the KEYLESS embed. The host asks for maplibre (attribute in js mode, ?provider=
|
|
// in iframe mode) and every tab must still paint — with ZERO requests to api.mapbox.com. That is the
|
|
// promise a keyless partner embed depends on, and the shell once broke it by not forwarding the engine.
|
|
const PROVIDER = argOf('--provider', '')
|
|
// KNOWN, ROUTED exemptions from the zero-Mapbox rule — each names its owner. Printed on every run so
|
|
// it cannot become invisible; DELETE the entry when the owner lands the fix (the gate then enforces it).
|
|
// KNOWN, ROUTED good-neighbour violations by NESTED plugins (key = '<tab id>:<scroll|url|title>').
|
|
// Same contract as MAPBOX_KNOWN: printed on every run, owner named, and DELETED in the same commit
|
|
// as the owner's fix so main never carries a stale exemption or a red gate on someone else's bug.
|
|
const NEIGHBOUR_KNOWN = {
|
|
// EMPTY ON PURPOSE, and by two separate fixes landing together in this merge. The research-map
|
|
// scrollIntoView entries were deleted by their owner when that fix landed; the two
|
|
// regions-dashboard entries (:url and :title) are deleted here in the same commit as the fix that
|
|
// makes region-dashboards honour profile-config.urlSync:false in writeUrl, syncTitle and
|
|
// popstate. Same contract as MAPBOX_KNOWN below: an exemption is printed on every run, names its
|
|
// owner, and is deleted in the SAME commit as the fix, so main never carries a stale exemption
|
|
// and never red-flags a bug that is already fixed.
|
|
}
|
|
// KNOWN, ROUTED gaps in a composite's internal link (not measurable as pass/fail yet: no DOM hook).
|
|
const LINKED_KNOWN = {
|
|
// upg-100-mfe.vue:143-148 answers `doxa-upg-100-list:show-on-map` with go('map') only; it never
|
|
// re-dispatches `doxa:select-zone` (the event upg-100-list.vue:1366-1381 isolates a zone on), so
|
|
// "Show on map" scrolls to the map without selecting that list's zone. Routed 2026-09-25.
|
|
'upg-100: "Show on map" does not select the zone on the map': 'upg100-plugin (upg-100-mfe.vue:143 re-dispatch doxa:select-zone)',
|
|
}
|
|
const MAPBOX_KNOWN = {
|
|
// pplr-data-maps (new Experimental bundle, exp-shepherd) loads the Mapbox GEOCODER script even when
|
|
// the host asked for maplibre: the only api.mapbox.com request in the whole keyless gallery, both
|
|
// modes, measured 2026-09-25 while that tab was open. Routed via dx-shepherd; delete this entry in
|
|
// the owner's fix commit so the gate enforces zero again.
|
|
'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js': 'exp-shepherd (pplr-data-maps loads the Mapbox geocoder under maplibre)',
|
|
// EMPTY ON PURPOSE. The one entry here — my-upg-100-list/upg-100-list.vue:1464 pinning
|
|
// mapbox-gl.css v3.3.0 regardless of engine — was fixed and the exemption deleted in the SAME
|
|
// COMMIT as the fix, so main never had a window where the gate red-flagged its own known bug and
|
|
// never had a window where a stale exemption hid a live one. Proven before deletion: this gate
|
|
// reported "provider=maplibre made 0 request(s) to api.mapbox.com" in BOTH js and iframe modes,
|
|
// with no KNOWN line printed at all.
|
|
//
|
|
// ⚠ NOT COVERED BY THIS MAP, and deliberately so: the generated leaf HEAD still preloads
|
|
// mapbox-gl.css v3.24.0 and mapbox-gl-geocoder v5.0.0. Different versions, different owner —
|
|
// that is generate-tree (bundler-1's lane), tracked as
|
|
// BUG-LEAF-PAGES-PRELOAD-MAPBOX-EVEN-UNDER-MAPLIBRE. This gate embeds the GALLERY, which does not
|
|
// load those leaves, which is why it reads zero today.
|
|
}
|
|
// ★ FREE PORTS, ASKED OF THE OS — never fixed ones. A stray server left on a fixed port by an
|
|
// earlier crashed run answered in place of the tree under test, and this gate PASSED a known-broken
|
|
// tree (caught by its own negative control, 2026-09-24): spawn failed with EADDRINUSE silently and
|
|
// the old server served the fixed build. A port the OS just handed out cannot be someone else's.
|
|
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
|
const TREE_PORT = await freePort()
|
|
const HOST_PORT = await freePort()
|
|
const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps`
|
|
|
|
if (!existsSync(resolve(ROOT, 'public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js'))) {
|
|
console.log('SKIP check-research-gallery-embed: no built gallery at public/doxa-maps/doxa-maps-spa/')
|
|
process.exit(2)
|
|
}
|
|
let chromium
|
|
try { ({ chromium } = await import('playwright')) } catch {
|
|
console.log('SKIP check-research-gallery-embed: playwright not installed'); process.exit(2)
|
|
}
|
|
|
|
let mode = 'js'
|
|
// NEIGHBOUR mode: the partner-page shape that exposes a badly-behaved guest. The gallery sits BELOW
|
|
// THE FOLD, under hostile inherited typography, on a page with its own #anchor and <title>.
|
|
const NEIGHBOUR_TITLE = 'Host research page'
|
|
const hostPage = () => {
|
|
if (mode === 'neighbour') return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>${NEIGHBOUR_TITLE}</title>
|
|
<style>body{margin:0;font:20px/3 Georgia,serif;letter-spacing:2px;color:#900} .fold{height:1400px}</style></head>
|
|
<body><div class=fold>host content above the fold</div><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa${PROVIDER ? ` provider="${PROVIDER}"` : ''}></doxa-maps-spa><p>after</p></body></html>`
|
|
const embed = mode === 'iframe'
|
|
? `<iframe src="${TREE}/doxa-maps-spa/doxa-maps-spa/index.html${PROVIDER ? '?provider=' + PROVIDER : ''}" style="width:100%;height:760px;border:0"></iframe>`
|
|
: `<script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa${PROVIDER ? ` provider="${PROVIDER}"` : ''}></doxa-maps-spa>`
|
|
return `<!doctype html><html><head><meta charset=utf-8><title>host</title>
|
|
<style>body{margin:0} header,footer{height:60px;background:#eee}</style></head>
|
|
<body><header id=hdr>HOST HEADER</header><main style="max-width:1200px;margin:0 auto">${embed}</main><footer>HOST FOOTER</footer></body></html>`
|
|
}
|
|
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' })
|
|
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(hostPage()) }).listen(HOST_PORT, 'localhost')
|
|
const cleanup = () => { try { tree.kill() } catch {} try { host.close() } catch {} }
|
|
await new Promise(r => setTimeout(r, 800))
|
|
|
|
// ★ SERVER IDENTITY BY CONTENT, before a single measurement. A readiness check that only waits for
|
|
// HTTP 200 can adopt a STALE server: if the spawn above died (EADDRINUSE; its output is ignored) and
|
|
// something else answers on the port, every result would describe someone else's tree. So the served
|
|
// manifest must be byte-identical to THIS checkout's. A mismatch is NOT MEASURED (exit 75), never a
|
|
// pass or a fail — the same code the shared browser locks use for "busy, re-run later".
|
|
{
|
|
const { readFileSync } = await import('node:fs')
|
|
const disk = readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8')
|
|
let served = ''
|
|
for (let i = 0; i < 20 && !served; i++) {
|
|
try { const r = await fetch(`http://127.0.0.1:${TREE_PORT}/doxa-maps/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ }
|
|
if (!served) await new Promise(r => setTimeout(r, 300))
|
|
}
|
|
if (served !== disk) {
|
|
console.log(`NOT MEASURED: the server on :${TREE_PORT} is not serving this checkout's tree (manifest ${served ? 'differs' : 'unreachable'}). Re-run.`)
|
|
cleanup(); process.exit(75)
|
|
}
|
|
}
|
|
|
|
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
|
|
const fails = []
|
|
const checked = {} // mode -> tabs actually driven; printed, and 0 is a FAIL (see the summary)
|
|
try {
|
|
for (mode of MODES) {
|
|
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
|
|
const own = new Set(), foreign = new Set(), mapbox = new Set()
|
|
let current = '(page load)'
|
|
const mapboxBy = {} // tab id (the one open when the request fired) -> distinct mapbox URLs
|
|
page.on('request', q => {
|
|
if (!/^https:\/\/api\.mapbox\.com\//.test(q.url())) return
|
|
const u = q.url().split('?')[0]
|
|
mapbox.add(u); (mapboxBy[current] = mapboxBy[current] || new Set()).add(u)
|
|
})
|
|
const note = (url, why) => (url.startsWith(`http://127.0.0.1:${TREE_PORT}/`) ? own : foreign).add(why + ' ' + url.split('?')[0])
|
|
page.on('response', r => { if (r.status() >= 400) note(r.url(), String(r.status())) })
|
|
page.on('requestfailed', q => { const e = (q.failure() && q.failure().errorText) || ''; if (!/ABORTED/.test(e)) note(q.url(), e) })
|
|
await page.goto(`http://localhost:${HOST_PORT}/`)
|
|
const frame = mode === 'iframe' ? await (await page.waitForSelector('iframe')).contentFrame() : page.mainFrame()
|
|
// A gallery that never boots is a named FAIL for this mode, never an uncaught crash: a crash
|
|
// skips every later mode and the summary, so it reads as "the gate broke" rather than a finding.
|
|
const booted = await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false)
|
|
if (!booted) { console.log(`FAIL [${mode}] the gallery did not boot within 60s`); fails.push(`[${mode}] the gallery did not boot within 60s`); await page.close(); continue }
|
|
|
|
if (mode === 'js') {
|
|
const g = await page.evaluate(() => {
|
|
const s = document.querySelector('doxa-maps-spa'), v = s.shadowRoot.querySelector('.vtl')
|
|
const a = s.getBoundingClientRect(), b = v.getBoundingClientRect()
|
|
return { host: [a.top, a.left, a.width, a.height].map(Math.round), vtl: [b.top, b.left, b.width, b.height].map(Math.round) }
|
|
})
|
|
const inside = g.vtl[0] >= g.host[0] - 1 && g.vtl[1] >= g.host[1] - 1 && g.vtl[2] <= g.host[2] + 1 && g.vtl[3] <= g.host[3] + 1 && g.host[3] >= 400
|
|
console.log(`${inside ? 'PASS' : 'FAIL'} [js] layout inside the host box — host ${g.host} layout ${g.vtl}`)
|
|
if (!inside) fails.push(`[js] layout escaped the <doxa-maps-spa> box (host ${g.host}, layout ${g.vtl})`)
|
|
}
|
|
|
|
const tabs = await frame.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => ({ id: t.id, discovered: !!t.discovered })))
|
|
for (const { id, discovered } of tabs) {
|
|
if (ONLY && !ONLY.includes(id)) continue
|
|
current = id
|
|
await frame.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
|
|
let v
|
|
for (let k = 0; k < 50; k++) {
|
|
await page.waitForTimeout(400)
|
|
v = await frame.evaluate(id => {
|
|
const vm = document.querySelector('doxa-maps-spa')._instance.proxy
|
|
const i = vm.tabs.findIndex(x => x.id === id), t = vm.tabs[i]
|
|
const el = vm._slots && vm._slots[i] && vm._slots[i].firstElementChild
|
|
let canvas = 0
|
|
const walk = r => { for (const n of r.querySelectorAll('*')) { if (n.tagName === 'CANVAS') canvas++; if (n.shadowRoot) walk(n.shadowRoot) } }
|
|
if (el) { walk(el); if (el.shadowRoot) walk(el.shadowRoot) }
|
|
return { state: t.state, error: t.error, canvas }
|
|
}, id)
|
|
if (v.state === 'error' || (v.state === 'ready' && (v.canvas || discovered && k > 5))) break
|
|
}
|
|
const needCanvas = !discovered
|
|
const ok = v.state === 'ready' && (!needCanvas || v.canvas > 0)
|
|
const why = v.state !== 'ready' ? `state ${v.state}${v.error ? ' — ' + v.error : ''}` : (needCanvas && !v.canvas ? 'mounted but painted no map canvas' : '')
|
|
checked[mode] = (checked[mode] || 0) + 1
|
|
console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] ${id}${why ? ' — ' + why : ''}`)
|
|
if (!ok) fails.push(`[${mode}] ${id}: ${why}`)
|
|
}
|
|
for (const f of own) { console.log(`FAIL [${mode}] tree request failed: ${f}`); fails.push(`[${mode}] ${f}`) }
|
|
if (PROVIDER === 'maplibre') {
|
|
for (const u of [...mapbox]) if (MAPBOX_KNOWN[u]) { console.log(`KNOWN [${mode}] ${u} — owner: ${MAPBOX_KNOWN[u]}`); mapbox.delete(u); for (const k in mapboxBy) { mapboxBy[k].delete(u); if (!mapboxBy[k].size) delete mapboxBy[k] } }
|
|
const ok = mapbox.size === 0
|
|
console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] provider=maplibre made ${mapbox.size} request(s) to api.mapbox.com`)
|
|
if (!ok) {
|
|
for (const [tab, urls] of Object.entries(mapboxBy)) {
|
|
console.log(` while ${tab} was open: ${urls.size} — ${[...urls].slice(0, 3).join(' , ')}`)
|
|
fails.push(`[${mode}] provider=maplibre: ${tab} hit api.mapbox.com (${urls.size} distinct URL(s))`)
|
|
}
|
|
}
|
|
}
|
|
for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`)
|
|
await page.close()
|
|
}
|
|
|
|
// ── GOOD-NEIGHBOUR PHASE (script embed only: an iframe has its own document, title and URL) ──
|
|
// A guest on a partner page must leave the page as it found it. Four rules, each measured after the
|
|
// gallery loads and again after every tab opens, at PHONE width (where Simple Research is the
|
|
// default tab, and where a scroll jump on load was measured, 2026-09-25):
|
|
// 1. the host page does not SCROLL (scrollIntoView on a nested element scrolls every ancestor,
|
|
// including the partner's page — a visitor is yanked down to the embed with no action);
|
|
// 2. the host URL (path, query, #anchor) is unchanged; 3. document.title is unchanged;
|
|
// 4. the host's inherited typography does not reach the gallery root.
|
|
if (!argOf('--mode', '') && !ONLY) {
|
|
mode = 'neighbour'
|
|
const page = await browser.newPage({ viewport: { width: 390, height: 800 } })
|
|
const START = `http://localhost:${HOST_PORT}/?page=research#top`
|
|
await page.goto(START)
|
|
const booted = await page.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false)
|
|
if (!booted) { fails.push('[neighbour] the gallery did not boot within 60s'); console.log('FAIL [neighbour] the gallery did not boot within 60s') }
|
|
await page.waitForTimeout(5000)
|
|
const look = () => page.evaluate(() => ({ y: Math.round(scrollY), href: location.href, title: document.title, active: document.querySelector('doxa-maps-spa')._instance.proxy.activeKey }))
|
|
const typo = await page.evaluate(() => { const c = getComputedStyle(document.querySelector('doxa-maps-spa').shadowRoot.querySelector('.vtl')); return { size: c.fontSize, ls: c.letterSpacing, lh: c.lineHeight } })
|
|
const typoOk = typo.size === '16px' && typo.ls === 'normal' && typo.lh === 'normal'
|
|
console.log(`${typoOk ? 'PASS' : 'FAIL'} [neighbour] host typography stops at the box — gallery root ${JSON.stringify(typo)}`)
|
|
if (!typoOk) fails.push(`[neighbour] host typography leaked into the gallery ${JSON.stringify(typo)}`)
|
|
const judge = (at, v) => {
|
|
const bad = []
|
|
if (v.y !== 0) bad.push(['scroll', `host page scrolled to y=${v.y}`])
|
|
if (v.href !== START) bad.push(['url', `host URL became ${v.href.replace(/^https?:\/\/[^/]+/, '')}`])
|
|
if (v.title !== NEIGHBOUR_TITLE) bad.push(['title', `host title became "${v.title}"`])
|
|
for (const [kind, why] of bad) {
|
|
const known = NEIGHBOUR_KNOWN[`${at}:${kind}`]
|
|
if (known) console.log(`KNOWN [neighbour] ${at}: ${why} — owner: ${known}`)
|
|
else { console.log(`FAIL [neighbour] ${at}: ${why}`); fails.push(`[neighbour] ${at}: ${why}`) }
|
|
}
|
|
if (!bad.length) console.log(`PASS [neighbour] ${at} leaves the host page alone`)
|
|
return bad.length
|
|
}
|
|
const first = await look()
|
|
judge(first.active, first)
|
|
const ids = await page.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => t.id))
|
|
for (const id of ids) {
|
|
// Reset the host between tabs, so each tab is judged on what IT did, not on a predecessor's.
|
|
await page.evaluate(({ u, t }) => { scrollTo(0, 0); history.replaceState(null, '', u); document.title = t }, { u: START, t: NEIGHBOUR_TITLE })
|
|
await page.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
|
|
await page.waitForTimeout(4000)
|
|
judge(id, await look())
|
|
}
|
|
checked.neighbour = ids.length
|
|
await page.close()
|
|
|
|
// ── LINKED PHASE — matrix cell D for the composites: "a filter in one part updates the other",
|
|
// driven INSIDE a gallery tab (the composites' own leaf pages are covered by verify:interop; this
|
|
// proves the link survives being nested one level deeper, in the gallery's document). Each check
|
|
// clicks the composite's REAL control and polls an observable in the OTHER part. Playwright CSS
|
|
// pierces open shadow roots, so one locator reaches through the gallery and the composite.
|
|
// Mechanics (found 2026-09-25, file:line in each owner's plugin):
|
|
// Big Picture dashboard facet → `dashboard-filter` emit → grid props; the grid's count
|
|
// line (.repel) is ABSENT until a filter applies, then reads "Showing N".
|
|
// Simple Research the two halves are INDEPENDENT BY DESIGN (research-map-plus-grid.vue:49-56),
|
|
// so D asserts each half narrows on its own control — the grid's search.
|
|
// Regions list `region-selected` → window `doxa-regions-dashboard:select` → the
|
|
// dashboard's h1.rd-title becomes the region.
|
|
// UPG-100 "Show on map" must bring the map; selecting the ZONE on it is a routed gap.
|
|
mode = 'js'
|
|
const lp = await browser.newPage({ viewport: { width: 1400, height: 900 } })
|
|
await lp.goto(`http://localhost:${HOST_PORT}/`)
|
|
const lbooted = await lp.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false)
|
|
const pane = () => lp.locator('doxa-maps-spa section.dms-pane:not([hidden])')
|
|
const open = async (id) => {
|
|
await lp.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
|
|
await lp.waitForFunction(id => { const vm = document.querySelector('doxa-maps-spa')._instance.proxy; const t = vm.tabs.find(x => x.id === id); return t && t.state === 'ready' }, id, { timeout: 60000 })
|
|
await lp.waitForTimeout(6000) // let the composite fetch + paint its data before touching it
|
|
}
|
|
const poll = async (fn, ms = 25000) => { const end = Date.now() + ms; let v; while (Date.now() < end) { v = await fn().catch(() => null); if (v && v.ok) return v; await lp.waitForTimeout(700) } return v || { ok: false, why: 'no observation' } }
|
|
const LINKS = {
|
|
'dashboard-plus-grid': async () => {
|
|
const count = pane().locator('.repel').filter({ hasText: /Showing/ })
|
|
const before = await count.count()
|
|
const facet = pane().locator('[data-facet="wagf_region"][data-value]:not(.dash-only)').first()
|
|
const val = await facet.getAttribute('data-value', { timeout: 20000 })
|
|
// The SUBJECT carries its own expected answer: the facet row prints how many groups it holds
|
|
// (e.g. "Asia 1,309"). Asserting the grid shows EXACTLY that — not merely "Showing <digits>" —
|
|
// fails on "Showing 0" (a subject with no data, or a broken link) AND on an unfiltered grid.
|
|
const rowNums = ((await facet.innerText()).match(/\d[\d,]*/g) || []).map(x => Number(x.replace(/,/g, '')))
|
|
const expect = rowNums.length ? rowNums[rowNums.length - 1] : 0
|
|
await facet.click()
|
|
return poll(async () => { const n = await count.count(); const txt = n ? await count.first().innerText() : ''; const got = Number(((txt.match(/Showing\s+([\d,]+)/) || [])[1] || '0').replace(/,/g, '')); return { ok: before === 0 && expect > 0 && got === expect, why: `facet wagf_region=${val} (row says ${expect}) → grid count line ${n ? '"' + txt.trim().slice(0, 40) + '"' : 'still absent'}` } })
|
|
},
|
|
'research-map-plus-grid': async () => {
|
|
const total = pane().locator('.repel').first()
|
|
const num = async () => Number(((await total.innerText({ timeout: 20000 })).match(/[\d,]+/) || ['0'])[0].replace(/,/g, ''))
|
|
const before = await num()
|
|
await pane().locator('.pgg-search input').first().fill('Kurd')
|
|
return poll(async () => { const after = await num(); return { ok: before > 0 && after > 0 && after < before, why: `grid search "Kurd": ${before} → ${after}` } })
|
|
},
|
|
'regions-dashboard': async () => {
|
|
const btn = pane().locator('button.region-card__link').first()
|
|
const name = (await btn.innerText({ timeout: 20000 })).trim()
|
|
const title = pane().locator('h1.rd-title').first()
|
|
const before = (await title.innerText({ timeout: 20000 }).catch(() => '')).trim()
|
|
await btn.click()
|
|
return poll(async () => { const t = (await title.innerText()).trim(); return { ok: t !== before && t.includes(name), why: `list "${name}" → dashboard title "${before}" → "${t}"` } })
|
|
},
|
|
'upg-100': async () => {
|
|
const tape = pane().locator('.card[data-list-id] button.tape.tape-h').first()
|
|
await tape.click({ timeout: 20000 })
|
|
return poll(async () => { const on = pane().locator('.viewsel button[aria-pressed="true"]'); const txt = (await on.first().innerText()).trim(); return { ok: /map/i.test(txt) || /карт|mapa|carte|地图|خريطة|मानचित्र|karte|mappa|hartă/i.test(txt), why: `"Show on map" → active view "${txt}"` } })
|
|
}
|
|
}
|
|
if (!lbooted) fails.push('[linked] the gallery did not boot within 60s')
|
|
for (const [id, run] of Object.entries(lbooted ? LINKS : {})) {
|
|
let r
|
|
try { await open(id); r = await run() } catch (e) { r = { ok: false, why: 'threw: ' + String(e.message || e).split('\n')[0].slice(0, 160) } }
|
|
console.log(`${r.ok ? 'PASS' : 'FAIL'} [linked] ${id}: ${r.why}`)
|
|
if (!r.ok) fails.push(`[linked] ${id}: ${r.why}`)
|
|
}
|
|
for (const [k, owner] of Object.entries(LINKED_KNOWN)) console.log(`KNOWN [linked] ${k} — owner: ${owner}`)
|
|
checked.linked = Object.keys(LINKS).length
|
|
await lp.close()
|
|
}
|
|
} finally { await browser.close(); cleanup() }
|
|
|
|
// ★ A MODE THAT RAN NOTHING HAS NOT PASSED. A flag lost on the way in (package.json once ended
|
|
// this entry in an inline # comment, so an appended --mode landed inside the comment) must not
|
|
// read as "both modes green": each requested mode has to have driven at least one tab.
|
|
for (const m of MODES) if (!checked[m]) fails.push(`[${m}] checked 0 tabs — this mode did not run`)
|
|
const tally = [...MODES, ...(checked.neighbour ? ['neighbour'] : []), ...(checked.linked ? ['linked'] : [])].map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
|
|
if (fails.length) {
|
|
console.log(`\nchecked: ${tally}`)
|
|
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)
|
|
process.exit(1)
|
|
}
|
|
console.log(`\ncheck-research-gallery-embed: PASS — the Research Gallery embeds on a foreign origin (${tally})`)
|