doxa-maps/internal/check-spa-tab-order.mjs
doxa-maps-88655c70 4cc4a1284b
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The last browser scripts refuse to measure a tree or dev server that is not this checkout
BUG-BROWSER-GATES-CAN-MEASURE-ANOTHER-WORKTREES-TREE, the remainder. A
static scan of every internal/ script that drives a browser at a local
target found eight with no identity guard (a9550858 and 59f497eb
guarded the rest):
  built tree  check-spa-tab-order -> requireThisTree
  dev server  long-frames-probe, mobile-sheet-probe, theme-flash-probe,
              plugin-gallery-smoke, share-roundtrip-smoke (--plugin-gallery),
              smoke-studio, legend-columns-probe (only when aimed at
              /plugin-designs/; its default target is the DX-life site
              it compares against on purpose)

A dev server has no built manifest, so the new
requireDevServesThisCheckout checks the source it serves. It fetches
three library files with ?raw and compares them byte for byte with
this checkout. Another tree's dev server answers differently or 403s,
and the gate exits 75 (NOT MEASURED). Vite answers a plain fetch with
the file's text and an import-style request with `export default "…"`;
both shapes are accepted, since the first version parsed only the
second and refused this checkout's own server.

Proven: against a dev server running from 319064a1's parent and a
built tree whose manifest differs by one byte, all eight exit 75 and
name the differing file (useMapInstance.js). Against this checkout,
check-spa-tab-order and smoke-studio PASS, and plugin-gallery-smoke
gets past the guard. A re-scan finds no unguarded script left.
2026-09-25 11:10:34 +00:00

157 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
/**
* check-spa-tab-order.mjs — the cap-5 product SPA must open on the Big Picture, in the Driver's order.
*
* WHAT THIS PROTECTS. The Driver specified the rail explicitly: "cap-5 tab order (Big Picture,
* UPG-100, Prayer Progress, Gospel Resources)". Tab 1 is not a preference — it is the LANDING
* SURFACE, the first thing anyone opening doxa-maps-spa sees, and the opening step of the
* onboarding ramp. Today that ordering is held only by the order of entries in a source array and
* by comments explaining why; nothing fails if someone reorders them.
*
* ★ WHY THIS IS EASY TO BREAK WITHOUT NOTICING. NAV_MODEL is also where sections, badges and
* narrow-screen flags live, so it gets edited for reasons that have nothing to do with ordering —
* and a row moved while adding a badge produces no error, no console warning, and a SPA that still
* works perfectly. It just opens somewhere else. The nav file's own comments record that "Big
* Picture" was once painted TWICE in the rail, from a one-row section that re-declared a row below;
* that is the same class of accident.
*
* ★ IT ASSERTS THE RENDERED RAIL, NOT THE SOURCE ARRAY. Parsing NAV_MODEL is what I tried first and
* it returned an EMPTY list — the entries do not carry the `id:` shape I assumed, and a regex over
* a 300-line commented array is guesswork either way. The rail is the thing the user sees, it is
* what the Driver described, and reading it requires no assumption about how it is built. A source
* parse could also pass while a rendering bug shows a different order.
*
* ORDER, NOT MEMBERSHIP. Checking that four names are present would pass on a rail that opens on
* Gospel Resources. The assertion is the SEQUENCE, matched as prefixes so a row may carry a
* subtitle ("Big Picture — dashboard + people-groups grid") without failing.
*
* PRECONDITION: a built tree and `bun run serve` on 4173. No Mapbox token needed — the rail renders
* before any map mounts, and heavy tabs lazy-mount on activation, which this never triggers.
*
* NEGATIVE-TESTED: swapping the first two entries of NAV_MODEL fails by name, printing expected vs
* actual side by side.
*/
import { chromium } from 'playwright'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/doxa-maps'
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
await __treeGuard(BASE, __ROOT, 'SPA-TAB-ORDER')
// The Driver's sequence, verbatim from the open-work brief. Prefix-matched (case-insensitive,
// whitespace-collapsed) so a row may carry its subtitle.
// The Driver's four, asserted as a RELATIVE ORDER rather than as positions 1-4.
//
// ⚠ THIS USED TO DEMAND A FOUR-ROW RAIL, and it had been FAILING ever since the rail gained its
// three-section structure (4c74bd7, 2026-09-23, "Restore three-section tab structure:
// Dashboards / Prayer Mobilization / Gospel Research"). MEASURED 2026-09-24: the rail renders
// SEVEN rows —
// Big Picture · Simple Research · Regions · UPG-100 List · Prayer Progress ·
// Cities with Internet · Gospel Resources
// — and the Driver's four are ALL PRESENT, at 0/3/4/6, in their specified relative order. The
// product honours the instruction; this gate was asserting a shape the instruction never named.
// It quoted the Driver correctly and then over-read it: "(Big Picture, UPG-100, Prayer Progress,
// Gospel Resources)" names an ORDER, not a row count, and nothing there forbids a row between.
//
// So: assert what was actually specified. Tab 1 must still be Big Picture — that IS positional,
// it is the landing surface — and the other three must appear after it, in order.
const EXPECTED = ['Big Picture', 'UPG-100', 'Prayer Progress', 'Gospel Resources']
const WALK = `(sel)=>{const o=[],s=new Set();const w=(r)=>{if(!r||s.has(r))return;s.add(r);try{o.push(...r.querySelectorAll(sel))}catch(e){};for(const e of r.querySelectorAll('*'))if(e.shadowRoot)w(e.shadowRoot)};w(document);return o}`
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } })
let rows = []
let err = null
try {
// ★ EXPLICIT index.html, never a directory URL. `bun run serve` is serve-strict.mjs, which 404s a
// directory request with no index fallback BECAUSE THE LIVE STATIC LAYER DOES. Under the old
// python3 -m http.server this line passed, since that server silently resolved the directory —
// so this gate was green locally and would have 404'd against production. Measured 2026-09-23:
// `.../doxa-maps-spa/doxa-maps-spa/` -> 404, `.../index.html` -> 200. The symptom was maximally
// misleading: a 404 page has no rail, so the gate reported "no .vtl-tab rows found in the composed
// tree", which reads as a missing tab strip rather than a missing page. Its own next line warns
// about exactly this confusion for a different cause (shadow roots), and that warning is what
// makes the wrong reading tempting. Same defect class serve-strict was built to expose after it
// hid a bad URL in verify-shell-scroll for a whole flight; a repo-wide sweep found this was the
// last remaining directory URL in internal/.
await page.goto(`${BASE}/doxa-maps-spa/doxa-maps-spa/index.html`, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(8000)
// ⚠ DEDUPE: '.vtl-tab' matches NESTED elements, so each rendered row comes back ~5 times —
// measured 35 matches for 7 real rows. The old code took the first four of that list, which
// were all fragments of rows 1 and 2, and then reported them as rows 1-4. Keep only the
// OUTERMOST match per row (one that has no .vtl-tab ancestor), which is the row itself.
rows = await page.evaluate(
`(${WALK})('.vtl-tab')
.filter(t => !t.parentElement || !t.parentElement.closest('.vtl-tab'))
.map(t => (t.textContent || '').trim().replace(/\\s+/g, ' '))`
)
} catch (e) {
err = e.message.slice(0, 160)
}
await browser.close()
if (err) {
console.error('SPA-TAB-ORDER: FAIL — could not load the cap-5 SPA: ' + err)
console.error(' Needs a built tree and `bun run serve` on 4173. Reported as a FAILURE, not a')
console.error(' skip: a gate that cannot reach its subject has not passed, it has not run.')
process.exit(1)
}
if (!rows.length) {
console.error('SPA-TAB-ORDER: FAIL — no .vtl-tab rows found in the composed tree.')
console.error(' The rail is inside a shadow root, so this walks recursively with a seen-Set; a')
console.error(' flat querySelectorAll returns 0 here and would read as "no tabs" rather than')
console.error(' "wrong tool".')
process.exit(1)
}
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Where each expected name lands in the RENDERED rail. -1 = absent.
const at = EXPECTED.map((want) => rows.findIndex((r) => norm(r).startsWith(norm(want))))
const fails = []
// 1. PRESENCE. A name the Driver specified must be in the rail at all.
EXPECTED.forEach((want, i) => {
if (at[i] === -1) fails.push(`"${want}" is not in the rail at all`)
})
// 2. TAB 1 IS POSITIONAL, and only tab 1. It is the landing surface — the first thing anyone
// opening the SPA sees — which is a claim about position, not merely about order.
if (at[0] > 0) {
fails.push(`tab 1 is "${(rows[0] || '').slice(0, 40)}", not "${EXPECTED[0]}" — the SPA opens `
+ 'somewhere other than the landing surface')
}
// 3. THE REST ARE RELATIVE. The instruction named an order, not a row count, so a row sitting
// between two of them is not a violation — a row OUT OF ORDER is.
for (let i = 1; i < EXPECTED.length; i++) {
if (at[i] !== -1 && at[i - 1] !== -1 && at[i] < at[i - 1]) {
fails.push(`"${EXPECTED[i]}" (row ${at[i] + 1}) comes before "${EXPECTED[i - 1]}" `
+ `(row ${at[i - 1] + 1}) — the Driver's order is reversed here`)
}
}
if (fails.length) {
console.error(`SPA-TAB-ORDER: FAIL — ${fails.length} problem(s) with the Driver's rail order.\n`)
for (const f of fails) console.error(' - ' + f)
console.error('\n Rendered rail:')
rows.forEach((r, i) => console.error(` ${i + 1}. ${r.slice(0, 52)}`))
console.error('\n Tab 1 is the LANDING SURFACE — the first thing anyone opening the product SPA')
console.error(' sees, and the opening step of the onboarding ramp. A reordered rail produces no')
console.error(' error and a SPA that still works; it just opens somewhere else.')
console.error(' The order lives in NAV_MODEL in plugin-designs/doxa-maps-spa/doxa-maps-spa.vue.')
process.exit(1)
}
console.log(`SPA-TAB-ORDER: PASS — the rail opens in the specified order (${rows.length} rows total).`)
// Print the WHOLE rendered rail and mark the Driver's four, so a reader can see the rows that
// sit between them rather than only the names being asserted. A gate that prints just its own
// four would hide the very structure that made the old positional assertion wrong.
rows.forEach((r, i) => {
const k = at.indexOf(i)
console.log(` ${String(i + 1).padStart(2)}. ${k >= 0 ? '★' : ' '} ${r.slice(0, 54)}`)
})
console.log(' Asserted on the RENDERED rail rather than on NAV_MODEL: the array is edited for')
console.log(' reasons unrelated to ordering (badges, sections, narrow-screen flags), and a source')
console.log(' parse can pass while a rendering bug shows something else.')