From ff8a318e5149e024bae588d40e6feb877fdc4b91 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:26 +0000 Subject: [PATCH] Let the map gate start its own server, so it will actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering a gate is not enough to make it run. verify:browser-all is invoked NOWHERE in this repo; build:unlocked runs only `bun run verify`, and verify-all.mjs skips anything marked browser:true unless --full is passed. That is precisely how two gates rotted here before — "both had rotted unnoticed precisely because the build chain has never run them". A gate whose precondition is "remember to start a server first" has a precondition nobody remembers. So the precondition is gone. If nothing answers, the gate spawns serve-strict on an ephemeral port — ephemeral so two gates running at once cannot collide on 4173 — and shuts it down afterwards. `bun run verify:gospel-map` now works from a clean shell with no setup. An explicitly named BASE_URL or argv is honoured and never auto-served: a caller naming a host means it, and quietly pointing at a different tree would be worse than skipping. Verified — a dead explicit URL SKIPs and starts nothing. ★ THE FAILURE PATH LEAKED A PROCESS AND THE PASS PATH DID NOT. process.exit(1) inside the try terminates before the finally block's cleanup runs, so a failing run left its spawned server alive: measured 0 strays after a pass, 1 after a fail. A gate that leaks only when it fails leaks exactly when someone is re-running it to see what broke. The exit code is now set, not thrown, and the process exits after cleanup. Re-measured from a clean baseline: 0 strays after pass, after fail, and after restore. One scare worth recording: a stray server DID show up mid-test and it was the orphan from the pre-fix run, six minutes older than the code being tested. Checking its start time rather than its existence is what told them apart. --- internal/verify-gospel-map.mjs | 83 +++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/internal/verify-gospel-map.mjs b/internal/verify-gospel-map.mjs index d153ad073..983c75fb3 100644 --- a/internal/verify-gospel-map.mjs +++ b/internal/verify-gospel-map.mjs @@ -26,34 +26,77 @@ * 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. + * ★ IT STARTS ITS OWN SERVER. Browser gates in this repo rot because they need a server + * someone else remembered to start: verify:browser-all is invoked NOWHERE, build:unlocked + * runs only `bun run verify`, and verify-all.mjs skips anything marked browser:true unless + * --full is passed. The repo has already lost two gates that way — "both had rotted + * unnoticed precisely because the build chain has never run them". A gate with a manual + * precondition is a gate that will not run, so this one removes the precondition: if + * nothing answers, it spawns serve-strict on an ephemeral port and shuts it down after. + * + * An explicit BASE_URL or argv[2] is honoured as-is and never auto-served — a caller + * naming a host means it, and silently pointing at a different tree would be worse than + * failing. + * + * USAGE: node internal/verify-gospel-map.mjs [baseUrl] (no setup needed) + * Skips loudly (exit 0) only if the tree is not built, the way CI sees it. */ import { chromium } from 'playwright' +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' 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 HERE = dirname(fileURLToPath(import.meta.url)) +const REPO = resolve(HERE, '..') +const EXPLICIT = !!(process.env.BASE_URL || process.argv[2]) + +let base = BASE +let server = null +const alive = async (b) => { + const r = await fetch(`${b}/doxa-maps/gospel-resources/resources-map/index.html`).catch(() => null) + return !!(r && r.ok) } +if (!(await alive(base))) { + if (EXPLICIT) { + console.log(`GOSPEL-MAP: SKIP — nothing served at ${base} (you named it explicitly, so none was started).`) + process.exit(0) + } + if (!existsSync(resolve(REPO, 'public/doxa-maps/gospel-resources/resources-map/index.html'))) { + console.log('GOSPEL-MAP: SKIP — no built tree. Run `bun run tree` (cheap) or a full build first.') + process.exit(0) + } + // Ephemeral port so two gates running at once cannot collide on 4173. + const port = 5100 + Math.floor(Math.random() * 800) + base = `http://127.0.0.1:${port}` + server = spawn(process.execPath, [resolve(HERE, 'serve-strict.mjs'), '--port', String(port), '--directory', resolve(REPO, 'public')], { stdio: 'ignore', detached: false }) + const until = Date.now() + 15000 + while (Date.now() < until && !(await alive(base))) await new Promise((r) => setTimeout(r, 250)) + if (!(await alive(base))) { + try { server.kill() } catch (_) {} + console.error('GOSPEL-MAP: FAIL — could not start serve-strict for the check.') + process.exit(1) + } + console.log(`GOSPEL-MAP: started serve-strict on ${port} for this run.`) +} +const PAGE_URL = `${base}/doxa-maps/gospel-resources/resources-map/index.html` + const fails = [] let linkCount = 0 +let exitCode = 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 }) + await page.goto(PAGE_URL, { 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. @@ -91,7 +134,7 @@ try { // 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`) + 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() @@ -162,12 +205,20 @@ try { if (fails.length) { console.error(`GOSPEL-MAP: FAIL — ${fails.length} problem(s):`) for (const f of fails) console.error(` ${f}`) - process.exit(1) + // ★ NOT process.exit() here. Calling it inside the try terminates the process before + // the finally block's cleanup runs, so a FAILING run left its spawned server alive + // while a passing run did not. Measured: 0 strays after a pass, 1 after a fail — a + // gate that leaks only when it fails leaks exactly when someone is re-running it. + exitCode = 1 + } else { + 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.`) } - 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() + // Never leave a stray server behind, on any exit path. + if (server) { try { server.kill('SIGTERM') } catch (_) {} } } +process.exit(exitCode)