diff --git a/documentation/readme-images/cap1-single-element.png b/documentation/readme-images/cap1-single-element.png index f0eeba79a..b31947baa 100644 Binary files a/documentation/readme-images/cap1-single-element.png and b/documentation/readme-images/cap1-single-element.png differ diff --git a/documentation/readme-images/cap2-adoption.png b/documentation/readme-images/cap2-adoption.png index 839ab645c..a9f8d5456 100644 Binary files a/documentation/readme-images/cap2-adoption.png and b/documentation/readme-images/cap2-adoption.png differ diff --git a/documentation/readme-images/cap2-engagement.png b/documentation/readme-images/cap2-engagement.png index 6fa8ee181..8301533a0 100644 Binary files a/documentation/readme-images/cap2-engagement.png and b/documentation/readme-images/cap2-engagement.png differ diff --git a/documentation/readme-images/cap2-prayer.png b/documentation/readme-images/cap2-prayer.png index 17a23f406..2a5227c94 100644 Binary files a/documentation/readme-images/cap2-prayer.png and b/documentation/readme-images/cap2-prayer.png differ diff --git a/documentation/readme-images/cap3-monolithic-research.png b/documentation/readme-images/cap3-monolithic-research.png index baf7ea68e..6e321342c 100644 Binary files a/documentation/readme-images/cap3-monolithic-research.png and b/documentation/readme-images/cap3-monolithic-research.png differ diff --git a/documentation/readme-images/cap4-upg100-list.png b/documentation/readme-images/cap4-upg100-list.png index ebd0b0f77..2c7b2c424 100644 Binary files a/documentation/readme-images/cap4-upg100-list.png and b/documentation/readme-images/cap4-upg100-list.png differ diff --git a/documentation/readme-images/cap4-upg100-mfe.png b/documentation/readme-images/cap4-upg100-mfe.png index 621dff181..71aca1a46 100644 Binary files a/documentation/readme-images/cap4-upg100-mfe.png and b/documentation/readme-images/cap4-upg100-mfe.png differ diff --git a/documentation/readme-images/cap5-doxa-maps-spa.png b/documentation/readme-images/cap5-doxa-maps-spa.png index 76fba14e0..d14073848 100644 Binary files a/documentation/readme-images/cap5-doxa-maps-spa.png and b/documentation/readme-images/cap5-doxa-maps-spa.png differ diff --git a/documentation/readme-images/cap5-plugin-studio.png b/documentation/readme-images/cap5-plugin-studio.png index de5514e0f..1f15fb1d0 100644 Binary files a/documentation/readme-images/cap5-plugin-studio.png and b/documentation/readme-images/cap5-plugin-studio.png differ diff --git a/documentation/readme-images/deploy1-iframe.png b/documentation/readme-images/deploy1-iframe.png index 59439dfe9..0cd39a19d 100644 Binary files a/documentation/readme-images/deploy1-iframe.png and b/documentation/readme-images/deploy1-iframe.png differ diff --git a/documentation/readme-images/shot-bundles.json b/documentation/readme-images/shot-bundles.json new file mode 100644 index 000000000..13f7d11eb --- /dev/null +++ b/documentation/readme-images/shot-bundles.json @@ -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" + } +} diff --git a/internal/check-shot-freshness.mjs b/internal/check-shot-freshness.mjs index 9648bf6ae..3fb100eab 100644 --- a/internal/check-shot-freshness.mjs +++ b/internal/check-shot-freshness.mjs @@ -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.') diff --git a/internal/readme-shots.mjs b/internal/readme-shots.mjs index 968f50c56..d0e5af3c7 100644 --- a/internal/readme-shots.mjs +++ b/internal/readme-shots.mjs @@ -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/.