doxa-maps/internal/check-shot-distinctness.mjs
Doxa Maps db51cb3d36
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 94f0e554
2026-09-25 22:19:32 +00:00

125 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
/**
* check-shot-distinctness.mjs — two README slots must not show the same thing.
*
* THE BUG THIS EXISTS TO PREVENT (real, 2026-09-21)
* README displayed cap4-upg100-list-page1.png and -page2.png under the captions "List — page 1"
* and "List — page 2". They were the SAME VIEW. The generator loaded the same URL twice and tried
* to reach a "page 2" by scrolling to the bottom and clicking a Next control —
*
* ★ NEITHER EXISTS. my-upg-100-list has NO pagination: no Next button, no pages. And
* window.scrollTo is a no-op because the map fills the viewport.
*
* Every attempt sat inside `try {} catch {}`, so the misses were SILENT and the shot reported OK.
* The two PNGs differed by 0.16% — pure render noise — so they were NOT byte-identical and no
* checksum comparison would have caught them either.
*
* ★ WHILE A REAL PROFILE WENT UNSHOT. The bundle ships THREE manifest-listed profiles
* (upg-100-mfe, upg-100-list, poster-wall) and poster-wall had no screenshot anywhere. The slot
* was faking a fourth view while a genuine one was missing — the same shape as the cap-5 studio
* mislabel found the day before. A missing artifact wearing a plausible one.
*
* WHAT IT CHECKS, AND THE ONE LEGITIMATE EXCEPTION
* Two shots of the SAME URL at the SAME VIEWPORT cannot differ except by noise, so that pair is
* always a defect. But the same URL at a DIFFERENT viewport is a real, intended figure:
* deploy2-mobile-spa shoots doxa-maps-spa at 390x844 to show the Capacitor mobile surface, while
* cap5-doxa-maps-spa shoots it at 1440x900 (2880x1800 vs 1170x2532 on disk). So the key is
* (url + viewport), not url alone — a url-only rule would have a false positive on day one and
* get switched off.
*
* WHY THIS AND check:shots BOTH EXIST
* check:shots proves a PNG is newer than the bundle it shows — freshness. This proves two slots
* are not the same picture — distinctness. A duplicate can be perfectly fresh.
*
* NEGATIVE-TESTED: re-adding a second shot of upg-100-list at the default viewport makes this
* FAIL naming both; removing it returns PASS. The real deploy2/cap5 pair does NOT trip it.
*/
import { readFileSync, existsSync, statSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const SRC = join(ROOT, 'internal', 'readme-shots.mjs')
const OUT = join(ROOT, 'documentation', 'readme-images')
if (!existsSync(SRC)) {
console.log('SHOT-DISTINCTNESS: SKIP — readme-shots.mjs not present.')
process.exit(0)
}
const src = readFileSync(SRC, 'utf8')
// Each `await shot('name', '/path', { ...opts })` — capture the opts blob so we can read `vp`.
const RE = /shot\(\s*'([^']+)'\s*,\s*(?:'([^']*)'|`([^`]*)`)\s*(?:,\s*(\{[\s\S]*?\}))?\s*\)/g
const shots = []
for (const m of src.matchAll(RE)) {
const [, name, quoted, templated, opts = ''] = m
const path = quoted ?? templated // '…' or `/${TREE_NAME}/…` — compared as written, so either form is consistent
const vp = opts.match(/vp:\s*\{\s*width:\s*(\d+)\s*,\s*height:\s*(\d+)/)
shots.push({ name, path, vp: vp ? `${vp[1]}x${vp[2]}` : 'default' })
}
// A matcher that finds almost nothing reads exactly like a clean pass — see check-quoted-scripts.
// FLOOR: every `await shot(` call must be parsed. If not, the matcher has drifted from the file (it did
// once: a codemod turned the paths into template literals and this gate saw 2 of 26), so the result is
// NOT MEASURED (exit 75), never a pass and never a product failure.
const CALLS = src.split('\n').filter((l, i, a) => /await shot\(/.test(l) && !/shot-floor: dynamic/.test(a[i - 1] || '')).length
console.log(`SHOT-DISTINCTNESS: ${shots.length} shot(s) declared.`)
if (shots.length !== CALLS) {
console.error(`\nSHOT-DISTINCTNESS: NOT MEASURED — parsed ${shots.length} of ${CALLS} shot( calls in readme-shots.mjs.`)
console.error(' The MATCHER is out of date with the file, not the shot list. Fix the regex, then re-run.')
process.exit(75)
}
const bySignature = new Map()
for (const s of shots) {
const key = `${s.path} @${s.vp}`
if (!bySignature.has(key)) bySignature.set(key, [])
bySignature.get(key).push(s.name)
}
const collisions = [...bySignature.entries()].filter(([, names]) => names.length > 1)
// Report the same-url/different-viewport pairs as intentional, so they stay visible.
const sameUrlDiffVp = new Map()
for (const s of shots) {
if (!sameUrlDiffVp.has(s.path)) sameUrlDiffVp.set(s.path, new Set())
sameUrlDiffVp.get(s.path).add(s.vp)
}
for (const [path, vps] of sameUrlDiffVp) {
if (vps.size > 1) console.log(` i SAME URL, DIFFERENT VIEWPORTS (intended): ${path} @ ${[...vps].join(', ')}`)
}
if (collisions.length) {
console.error(`\nSHOT-DISTINCTNESS: FAIL — ${collisions.length} slot pair(s) capture the SAME view:`)
for (const [key, names] of collisions) {
console.error(` ✗ ${names.join(' and ')}\n both load ${key}`)
}
console.error(
'\n Two shots of one URL at one viewport cannot differ except by render noise, so they are\n' +
' not byte-identical and a checksum will not catch them. If a slot is meant to show a\n' +
' DIFFERENT state, drive it with an assertion that FAILS when the control is absent —\n' +
' a click wrapped in try/catch makes a missing feature look like a successful shot.\n' +
' And check first whether the state exists at all: the original defect was a "page 2"\n' +
' for a list that has no pagination, while a real third profile went unshot.'
)
process.exit(1)
}
// Belt and braces: identical bytes are always wrong, whatever the declared opts say.
if (existsSync(OUT)) {
const seen = new Map()
for (const s of shots) {
const p = join(OUT, s.name + '.png')
if (!existsSync(p)) continue
const sig = `${statSync(p).size}`
if (!seen.has(sig)) seen.set(sig, [])
seen.get(sig).push(s.name)
}
const sameSize = [...seen.values()].filter((v) => v.length > 1)
for (const group of sameSize) {
console.log(` i identical file size (check by eye if unexpected): ${group.join(', ')}`)
}
}
console.log('SHOT-DISTINCTNESS: PASS — every slot captures a distinct url+viewport.')