diff --git a/internal/verify-browser-all.mjs b/internal/verify-browser-all.mjs index 1d158c8bb..5c6cd8ef9 100644 --- a/internal/verify-browser-all.mjs +++ b/internal/verify-browser-all.mjs @@ -66,6 +66,10 @@ const GATES = [ { script: 'verify:5-caps', why: 'all 5 capability levels mount and render' }, { script: 'smoke:embeds', why: 'every shipped leaf page mounts its element' }, { script: 'verify:map-paint', why: 'every map leaf paints a real canvas' }, + // verify:map-paint proves a canvas painted; this proves the gospel map's PINS are on it + // and answer a tap. It also scans every link in the index against the screened-publisher + // list, which is a doctrine boundary rather than a rendering one. + { script: 'verify:gospel-map', why: 'the gospel map renders its pins, answers a tap, and links only screened publishers' }, { script: 'verify:gate1', why: 'the Gate-1 chain filters live data end to end' }, { script: 'check:a11y-button-names', why: 'every visible button has an accessible name' }, { script: 'check:nav-reachable', why: 'a phone can open the cap-5 tab rail' }, diff --git a/internal/verify-gospel-map.mjs b/internal/verify-gospel-map.mjs new file mode 100644 index 000000000..d153ad073 --- /dev/null +++ b/internal/verify-gospel-map.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * verify-gospel-map.mjs — the gospel-resources map must render its pins and answer a tap. + * + * WHY THIS IS A GATE AND NOT A SCRATCH PROBE. Every browser check written for this bundle + * lived in _qa-cycle/, which is gitignored: a dozen probes that proved the pins render, the + * sheet opens with real links, and the deep link filters the table — all of which vanish + * with the working tree. check:coverage-consistent guards the DATA and cannot see the map. + * The repo has carded orphaned gates twice; this is the same loss one step earlier, a gate + * that was never committed at all. + * + * WHAT IT ASSERTS, against the SHIPPED tree through the production-parity server: + * 1. the element mounts and exposes window.__grState — a stale-SRI leaf silently refuses + * to execute its script, which looks exactly like a mount failure; + * 2. every pin in the source is RENDERED, not merely loaded; + * 3. tapping a pin opens a group whose language is NAMED and whose links are real URLs + * belonging to the screened hosts — never a fabricated or off-list link; + * 4. the gap filter actually filters: fewer features, and only tier 0 left; + * 5. the sheet's "every resource" link carries the group's own ISO code. + * + * ★ WAIT FOR IDLE BEFORE COUNTING. queryRenderedFeatures before first paint returns 0, + * which is indistinguishable from a layer that is not painting — the first version of this + * check reported a false negative on itself for exactly that reason. + * + * ★ AND THE HOST LIST IS THE DOCTRINE BOUNDARY, enforced here rather than trusted: only + * eBible/PNG.Bible/Door43/JESUS Film may appear. A link to an excluded publisher would be + * a doctrinal failure, not a broken link, so it fails this gate loudly. + * + * USAGE: needs a server. `bun run serve` (serve-strict on 4173), then + * node internal/verify-gospel-map.mjs [baseUrl] + * Override with BASE_URL. Skips loudly (exit 0) if the tree is not built, the way CI sees it. + */ +import { chromium } from 'playwright' + +const BASE = (process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173').replace(/\/+$/, '') +const PAGE = `${BASE}/doxa-maps/gospel-resources/resources-map/index.html` +const GL = ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox'] +// The screened publishers. Anything else is a doctrine failure, not a typo. +const ALLOWED = /^(https:\/\/)?([a-z0-9-]+\.)*(ebible\.org|png\.bible|alkitab\.pw|git\.door43\.org|jesusfilm\.org|arc\.gt)(\/|$)/i + +const res = await fetch(PAGE).catch(() => null) +if (!res || !res.ok) { + console.log(`GOSPEL-MAP: SKIP — ${PAGE} not served (${res ? res.status : 'no server'}).`) + console.log(' Start one with: bun run serve (serve-strict, production parity)') + process.exit(0) +} + +const fails = [] +let linkCount = 0 +const browser = await chromium.launch({ headless: true, args: GL }) +const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }) +const pageErrors = [] +page.on('pageerror', (e) => pageErrors.push(String(e))) + +try { + await page.goto(PAGE, { waitUntil: 'networkidle', timeout: 60000 }) + // 1 — mounted. A stale-SRI leaf never executes, and that reads as "did not mount". + await page.waitForFunction(() => typeof window.__grState === 'function', { timeout: 45000 }) + // ★ idle FIRST: counting before first paint returns 0 and looks like a dead layer. + await page.evaluate(() => new Promise((r) => { + const m = window.__grMap + m.loaded() ? r() : m.once('idle', r) + setTimeout(r, 8000) + })) + + const st = await page.evaluate(() => window.__grState()) + if (!st.hasLayer || !st.hasSource) fails.push('pin layer or source missing') + // 2 — rendered, not merely loaded + if (!st.pins || st.rendered !== st.pins) { + fails.push(`${st.rendered} of ${st.pins} pins rendered`) + } + + // 3 — a tap answers, with a named language and real links + const opened = await page.evaluate(() => { + const f = window.__grMap.getSource('gr-pin-src')._data.features + for (const x of f) { + window.__grOpenPin(x.properties.i) + const g = window.__grState().group + if (g && (g.scripture_url || g.film_url)) { + return { pg: g.people_group, lang: g.language, iso: g.iso639_3, s: g.scripture_url, f: g.film_url } + } + } + return null + }) + if (!opened) fails.push('no pin opened a group carrying a link') + else if (!String(opened.lang || '').trim()) fails.push(`${opened.pg} opened with no language name`) + + // ★ EVERY link in the index the page loaded, not just the one group we opened. The + // first version checked only the opened group's two URLs — a sample of 1 out of 2,685 + // codes — so an excluded publisher injected anywhere else passed cleanly. MEASURED: a + // jw.org URL planted in the served data did NOT fail this gate until the scan covered + // the whole index. The doctrine boundary is the one thing here that must never be + // spot-checked: a single off-list link is a doctrinal failure, not a broken link. + const idxRes = await fetch(`${BASE}/doxa-maps/assets/tables/people-group-pins.json`) + if (!idxRes.ok) fails.push(`pin index not served (${idxRes.status})`) + else { + const idx = await idxRes.json() + const bad = [] + let scanned = 0 + for (const [c, v] of Object.entries(idx.codes || {})) { + for (const u of [v.s, v.f, ...(v.d || []).map((d) => d.u)]) { + if (!u) continue + scanned++ + if (!ALLOWED.test(u)) bad.push(`${c} -> ${u}`) + } + } + if (!scanned) fails.push('pin index carries no links at all — this check would pass vacuously') + if (bad.length) fails.push(`${bad.length} link(s) on an unscreened publisher (e.g. ${bad[0]})`) + linkCount = scanned + } + + // 5 — the table deep link carries this group's own code + await page.waitForTimeout(400) + const href = await page.evaluate(() => { + const walk = (r) => { + for (const el of r.querySelectorAll('*')) { + const h = el.getAttribute && el.getAttribute('href') + if (el.tagName === 'A' && h && /data-tables/.test(h)) return h + if (el.shadowRoot) { const f = walk(el.shadowRoot); if (f) return f } + } + return null + } + return walk(document) + }) + if (opened?.iso && (!href || !href.includes(`iso=${opened.iso}`))) { + fails.push(`table link does not carry iso=${opened.iso} (got ${href})`) + } + + // 4 — the gap filter filters + const filtered = await page.evaluate(async () => { + const click = (i) => { + const walk = (r) => { + const b = [...r.querySelectorAll('.gm-kbtn')] + if (b[i]) { b[i].click(); return true } + for (const el of r.querySelectorAll('*')) if (el.shadowRoot && walk(el.shadowRoot)) return true + return false + } + return walk(document) + } + const m = window.__grMap + const before = m.queryRenderedFeatures({ layers: ['gr-pins'] }).length + click(2) + // ★ WAIT FOR IDLE, not a fixed sleep. setFilter is applied asynchronously and the + // layer re-renders after it; querying on a 900ms timer read the layer MID-TRANSITION + // and still saw tier 1 and 2 features. MEASURED at 1 failure in 3 runs — a gate that + // fails a third of the time on correct code is a gate that gets switched off, which + // is worse than not having written it. The file's own header says to wait for idle + // before counting; clause 4 was not doing it. + await new Promise((r) => { m.once('idle', r); setTimeout(r, 6000) }) + const after = m.queryRenderedFeatures({ layers: ['gr-pins'] }) + return { before, after: after.length, tiers: [...new Set(after.map((f) => f.properties.tier))] } + }) + if (!(filtered.after > 0 && filtered.after < filtered.before)) { + fails.push(`gap filter did not reduce the layer (${filtered.before} -> ${filtered.after})`) + } + if (filtered.tiers.length !== 1 || filtered.tiers[0] !== 0) { + fails.push(`gap filter left tiers ${JSON.stringify(filtered.tiers)}, expected only [0]`) + } + + if (pageErrors.length) fails.push(`page error: ${pageErrors[0].slice(0, 120)}`) + + if (fails.length) { + console.error(`GOSPEL-MAP: FAIL — ${fails.length} problem(s):`) + for (const f of fails) console.error(` ${f}`) + process.exit(1) + } + console.log(`GOSPEL-MAP: PASS — ${st.pins} pins rendered; "${opened.pg}" (${opened.lang}) opens with`) + console.log(` a named language; all ${linkCount} links in the index are on screened publishers;`) +console.log(` the table link carries iso=${opened.iso}; the gap filter`) + console.log(` cuts ${filtered.before} features to ${filtered.after}, all tier 0.`) +} finally { + await browser.close() +} diff --git a/package.json b/package.json index d24c0b604..e756370f8 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:coverage-consistent && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", + "verify:gospel-map": "node internal/verify-gospel-map.mjs # the gospel-resources MAP: pins render (not merely load), a tap opens a named group, EVERY link in the index is on a screened publisher, the table deep-link carries the group's ISO, and the gap filter leaves only tier 0. Needs a server (bun run serve); SKIPS loudly without one.", "check:sri": "node internal/sri-consistency-check.mjs", "check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.", "check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",