check:shots compares bundle content, not dates; retake the stale shots
Some checks are pending
build-gate / build (push) Waiting to run

A retake that came out byte-identical left the PNG unchanged, git dated it by
its old commit, and the gate called a correct, just-taken picture stale. No
clock can see that case. readme-shots now writes shot-bundles.json (shot ->
sha256 of the bundle it was taken from), and check:shots compares that hash
with the bundle built now; dates are only the fallback for unrecorded shots.

Negative-tested: one byte appended to the doxa-simple-map bundle fails exactly
the three cap2 shots; restoring it passes.

Also retook the eight shots a rebuild had made stale since e2beec9.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 21:53:25 +00:00
parent 13a1225182
commit bf7fc46dcd
13 changed files with 81 additions and 4 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1,018 KiB

View file

@ -0,0 +1,42 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
"sha256": "d48c17dc369294301d17a4d29fc517cc7c2c2b18561b6d416ae02a9fbfb60c33"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
"sha256": "a94db2c47d598dbc59daec6903afaac93862add9c0b7a532891b9c3c0608ce02"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
"sha256": "a94db2c47d598dbc59daec6903afaac93862add9c0b7a532891b9c3c0608ce02"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
"sha256": "a94db2c47d598dbc59daec6903afaac93862add9c0b7a532891b9c3c0608ce02"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "eb305958dffd4a067de6f00155c3f396452adfc6d20e842cc9ae4f921e54f44a"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
"sha256": "ff98986714934370a76742e3aa492a15ce67b959888f03092a453595103313d2"
},
"cap4-upg100-mfe": {
"bundle": "my-upg-100-list",
"sha256": "ff98986714934370a76742e3aa492a15ce67b959888f03092a453595103313d2"
},
"cap4-upg100-poster-wall": {
"bundle": "my-upg-100-list",
"sha256": "ff98986714934370a76742e3aa492a15ce67b959888f03092a453595103313d2"
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
"sha256": "c7b1aad1e3bd4bbe936858f2159c566c1ef2e24fc9636a3446b1e10723d86502"
},
"cap5-plugin-studio": {
"bundle": "plugin-studio",
"sha256": "c3d32d94d2df407fc35cea91faf0d46670abf52a63d37000486760361a588a5c"
}
}

View file

@ -25,6 +25,11 @@
* Shots whose path is not a /doxa-maps/ leaf (the synthetic iframe host) are REPORTED as skipped
* rather than dropped — an unchecked shot must be visible, not invisible.
*
* CONTENT FIRST, CLOCKS ONLY AS A FALLBACK. readme-shots writes shot-bundles.json: for each shot,
* the sha256 of the bundle it was taken from. A matching hash means the picture shows exactly the
* bundle built now; a different one means it is stale. No date is involved, so a clone, a rebase
* or a retake that came out byte-identical cannot fool it. Shots with no record yet fall back to:
*
* WHICH CLOCK: GIT, NOT THE FILESYSTEM, for any file that is committed and unmodified.
* This compared mtimes, and a clone (or a new worktree) writes files in whatever order git checks
* them out — so every fresh checkout failed, pristine main included (11 shots "2s behind" their
@ -33,11 +38,12 @@
* tarball) falls back to its mtime, because then the working copy IS the newest version.
*
* NEGATIVE-TESTED: appending a byte to public/doxa-maps/doxa-simple-map/doxa-simple-map.js makes
* this FAIL naming the three cap2-* shots (a dirty file is dated by mtime); reverting it returns it
* to PASS. A bare `touch` no longer fails it — a touch changes no content, so nothing is stale.
* this FAIL naming exactly the three cap2-* shots (their recorded hash no longer matches);
* reverting it returns it to PASS. A bare `touch` does not fail it: it changes no content.
*/
import { readFileSync, existsSync, statSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -51,6 +57,9 @@ if (!existsSync(SHOTS_SRC) || !existsSync(OUT)) {
process.exit(0)
}
const SHOT_BUNDLES = join(OUT, 'shot-bundles.json')
const recorded = existsSync(SHOT_BUNDLES) ? JSON.parse(readFileSync(SHOT_BUNDLES, 'utf8')) : {}
// Dirty = modified or untracked under the two trees compared. null = git unavailable -> mtimes only.
const git = (...a) => execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
let dirty = null
@ -86,6 +95,18 @@ for (const { name, path } of shots) {
if (!existsSync(bundle)) { skipped.push(`${name} (bundle ${m[1]} not built)`); continue }
checked++
// FIRST CHOICE: content, not clocks. readme-shots records the sha256 of the bundle each picture
// was taken from. Same hash = the picture shows exactly this bundle, however old either file's
// date. A different hash = the bundle changed since, so the picture is stale. This also covers
// a retake that came out byte-identical, which no clock can see: git dates an unchanged PNG by
// its old commit, and the gate called a correct, just-taken picture stale.
const rec = recorded[name]
if (rec && rec.bundle === m[1]) {
const now = createHash('sha256').update(readFileSync(bundle)).digest('hex')
if (now !== rec.sha256) stale.push(`${name}.png was shot from a different ${m[1]} bundle than the one built now`)
continue
}
// FALLBACK, for a shot with no record yet: clocks.
const b = stamp(bundle), p = stamp(png)
if (b.ms > p.ms) {
stale.push(`${name}.png is OLDER than the bundle it shows (${m[1]}) — ` +
@ -109,4 +130,4 @@ if (missing.length || stale.length) {
console.error(' A stale screenshot looks exactly like a current one — that is why this is a gate.')
process.exit(1)
}
console.log('SHOT-FRESHNESS: PASS — every screenshot is newer than the bundle it shows.')
console.log('SHOT-FRESHNESS: PASS — every screenshot shows the bundle that is built now.')

View file

@ -12,7 +12,9 @@
*/
import { chromium } from 'playwright'
import { createServer } from 'node:http'
import { readFile, stat, mkdir } from 'node:fs/promises'
import { readFile, stat, mkdir, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join, extname, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -86,6 +88,10 @@ const browser = await chromium.launch({ args: [
// ★ A SHOT THAT FAILS MUST NOT SILENTLY PRODUCE A README IMAGE. Collected and asserted at the end.
const failures = []
// documentation/readme-images/shot-bundles.json: shot name -> the sha256 of the bundle it was shot
// from. Merged, so an ONLY= run updates just the shots it took.
const SHOT_BUNDLES = resolve(HERE, '..', 'documentation', 'readme-images', 'shot-bundles.json')
const shotBundles = existsSync(SHOT_BUNDLES) ? JSON.parse(readFileSync(SHOT_BUNDLES, 'utf8')) : {}
const ONLY = process.env.ONLY ? process.env.ONLY.split(',') : null
// mapbox canvas selector to wait on (leaf pages mount a custom element with a canvas)
@ -183,6 +189,13 @@ async function shot(name, path, {
if (clip) opts.clip = clip
await page.screenshot(opts)
console.log(' OK', name + '.png', `<- ${path} (${ready} elements)`)
// Record WHICH BUNDLE this picture is of, by content hash, so check:shots can tell a current
// picture from a stale one without trusting any clock (see SHOT_BUNDLES below).
const lm = path.match(/^\/doxa-maps\/([^/]+)\//)
const bundleJs = lm && join(ROOT, 'doxa-maps', lm[1], `${lm[1]}.js`)
if (bundleJs && existsSync(bundleJs)) {
shotBundles[name] = { bundle: lm[1], sha256: createHash('sha256').update(readFileSync(bundleJs)).digest('hex') }
}
} catch (e) {
console.log(' ERR', name, e.message.split('\n')[0])
failures.push(name + ': ' + e.message.split('\n')[0])
@ -361,6 +374,7 @@ if (process.env.DXLIFE_URL) {
await browser.close()
server.close()
await writeFile(SHOT_BUNDLES, JSON.stringify(Object.fromEntries(Object.entries(shotBundles).sort()), null, 1) + '\n')
// ★ EXIT NON-ZERO ON ANY FAILED SHOT. Previously this printed DONE unconditionally, so a run
// where every map came back blank still reported success and the stale PNGs stayed in documentation/.