doxa-maps/internal/check-shot-distinctness.mjs
doxa-maps-88655c70 2509a6e347
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
README: three outputs, not five methods; a Languages section; screenshot gates see every shot again
- Deployment is now organised around what the build makes: (1) one web
  folder (536 files, 76 MiB), embedded by iframe and JavaScript (proven) and
  WordPress (just for fun); (2) the mobile app (Capacitor, built); (3) the
  desktop app (Tauri, proof of concept). A hosting table says where the folder
  can go: dx-life's public/, Cloudflare Pages drag-and-drop (fits its 1,000-file
  / 25 MiB limits), GitHub + jsDelivr (JS embeds only: measured, jsDelivr serves
  HTML as text/plain), or any web server, with the one _headers line JS embeds
  need. No Docker. The diagram and the map-cruncher picture say three outputs.
- Languages: 11 locales via ?lang=, Arabic right to left, with an Arabic shot of
  UPG-100 (Big Picture still leaks English in Arabic; reported). The partner
  profile-config locale is deliberately not documented until it is fixed.
- check-shot-freshness and check-shot-distinctness parsed only '...' paths, so
  after 44ef31b2 turned them into `/${TREE_NAME}/...` templates they saw 2 of 26
  shots: distinctness failed, freshness passed vacuously and hid 22 stale shots.
  Both now accept either form and resolve TREE_NAME, and exit 75 (NOT MEASURED)
  unless every `await shot(` call is parsed (the one dynamic dx-life call is
  marked). Negative-tested. The 22 shots are retaken and checked by eye.
- how-to: the shell no longer overwrites the Studio (it now lives in the output
  folder); README serve line points at doxa-maps/index.html.
- Docs follow the rename to dx-maps (09014f8a): every output-folder path and URL in
  README, how-to, reference, CONTRIBUTING, the CLI section and the develop guide now says
  public/dx-maps/. The repo name, the config file name, the WordPress plugin folder and
  the doxa-* tag names are unchanged (tags are a separate landing).
- Hosting: a live example, the dx-life branch research-gallery, which drops the folder
  into dx-life public/ and fills /research with the Research Gallery.
2026-09-25 19:40:03 +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.')