Commit the map check, so the browser path survives this flight
Every browser check written for this bundle lived in _qa-cycle/, which is gitignored: a dozen probes proving the pins render, the sheet opens with real links and the deep link filters the table — all of which disappear with the working tree. check:coverage-consistent guards the DATA and cannot see the map at all. The repo has carded orphaned gates twice; this is the same loss one step earlier, a gate that was never committed. It asserts, against the SHIPPED tree through the production-parity server: the element mounts and exposes its state handle (a stale-SRI leaf silently refuses to execute, which looks identical to a mount failure); every pin in the source is RENDERED, not merely loaded; a tap opens a group with a NAMED language; the table deep-link carries that group's own ISO code; and the gap filter leaves only tier 0. ★ IT TOOK THREE TRIES TO MAKE IT WORTH TRUSTING, and each failure is the point. FIRST it was FLAKY — 1 run in 3. Clause 4 waited a fixed 900ms after clicking the filter, but setFilter is applied asynchronously, so it queried mid-transition and still saw tier 1 and 2. It now waits for the map's own idle event. 5 of 5 runs clean. A gate that fails a third of the time on correct code is a gate that gets switched off. SECOND its doctrine check had a SAMPLE SIZE OF 1. It validated the two URLs of the one group it happened to open — 1 of 2,685 codes — so a jw.org link planted anywhere else passed cleanly. MEASURED: the injection did not fail the gate until the scan covered the whole index. It now checks all 3,550 links, and fails if the index carries none at all, because a scan over nothing passes vacuously. THIRD, adversarially tested end to end: with jw.org injected on code `tel` the gate exits 1 naming the publisher; restored, it exits 0; the file's md5 is unchanged either side. Registered in package.json and in verify-browser-all's GATES array — check:no-orphan-gates confirms it, and check:quoted-scripts passes. Without a server it SKIPS loudly with the command to start one, rather than passing quietly.
This commit is contained in:
parent
02351725ba
commit
3d3586b5f9
3 changed files with 178 additions and 0 deletions
|
|
@ -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' },
|
||||
|
|
|
|||
173
internal/verify-gospel-map.mjs
Normal file
173
internal/verify-gospel-map.mjs
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue