- 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 after44ef31b2turned 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.
125 lines
6.2 KiB
JavaScript
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.')
|