test(screenshots): require every PNG to be output of the current screenshot run

New gate tests/kb-update/test-screenshots-manifest.test.mjs. The screenshot runner will write playground/screenshots/MANIFEST.json (output directory, sha256 per PNG, sha256 of the demo state it rendered). The gate fails when a PNG in the tree is not in the manifest, an entry is missing or changed, the demo state changed after the run, or a doc points at another screenshot directory.

Chose a manifest written by run.mjs over a hand-kept hash list, because the runner is the only thing that can vouch for its own output, and the demo hash turns screenshots older than the demo into a failing test instead of a review finding.

Red on 715950b: 4/4 fail, 100 of 100 PNGs unlisted, manifest missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-23 14:36:03 +02:00
commit fa73e2d880
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q

View file

@ -0,0 +1,97 @@
// tests/kb-update/test-screenshots-manifest.test.mjs
// Preventive gate: every screenshot in the tree is output of the current
// screenshot runner (`tests/screenshot/run.mjs`) against the current demo.
//
// run.mjs writes `playground/screenshots/MANIFEST.json` at the end of a run:
// the output directory, the sha256 of every PNG it wrote, and the sha256 of
// the demo state block (`<script id="demo-state-v1">`) in the playground HTML
// it rendered. The gate fails when
// - a PNG in the tree is not in the manifest (an older set, or a hand-added file),
// - a manifest entry is missing on disk or its bytes differ,
// - the demo state changed after the screenshots were taken,
// - a doc points readers at a screenshot directory other than the manifest's.
// Fix: regenerate the screenshots from the current demo (tests/screenshot/README.md).
//
// Population: tracked + untracked-not-ignored files (`git ls-files --cached
// --others --exclude-standard`) that exist on disk, so the gate sees the tree
// the same way before and after a commit.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const pluginRoot = fileURLToPath(new URL('../../', import.meta.url));
const MANIFEST = 'playground/screenshots/MANIFEST.json';
const PLAYGROUND_HTML = 'playground/ms-ai-architect-playground.html';
const DOCS_POINTING_AT_SCREENSHOTS = ['README.md', 'docs/playground.md', 'tests/screenshot/README.md'];
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
function pngsInTree() {
return execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.png'], {
cwd: pluginRoot,
encoding: 'utf8',
})
.split('\0')
.filter(Boolean)
.filter((rel) => existsSync(pluginRoot + rel));
}
function loadManifest() {
const path = pluginRoot + MANIFEST;
assert.ok(existsSync(path), `${MANIFEST} missing — regenerate the screenshots with tests/screenshot/run.mjs`);
return JSON.parse(readFileSync(path, 'utf8'));
}
function demoStateBlock() {
const html = readFileSync(pluginRoot + PLAYGROUND_HTML, 'utf8');
const m = html.match(/<script type="application\/json" id="demo-state-v1">([\s\S]*?)<\/script>/);
assert.ok(m, `demo-state-v1 block not found in ${PLAYGROUND_HTML}`);
return m[1];
}
test('every PNG in the tree is listed in the screenshot manifest', () => {
const pngs = pngsInTree();
const manifest = existsSync(pluginRoot + MANIFEST) ? loadManifest() : { dir: '', files: {} };
const listed = new Set(Object.keys(manifest.files).map((name) => `${manifest.dir}/${name}`));
const unlisted = pngs.filter((rel) => !listed.has(rel));
assert.deepEqual(unlisted, [], `${unlisted.length} of ${pngs.length} PNGs are not output of the current screenshot run`);
});
test('every manifest entry exists with the recorded bytes', () => {
const { dir, files } = loadManifest();
const entries = Object.entries(files);
assert.ok(entries.length > 0, 'manifest lists no files');
const wrong = entries
.filter(([name, hash]) => {
const path = `${pluginRoot}${dir}/${name}`;
return !existsSync(path) || sha256(readFileSync(path)) !== hash;
})
.map(([name]) => name);
assert.deepEqual(wrong, [], `${wrong.length} of ${entries.length} manifest entries are missing or changed`);
});
test('screenshots were taken of the current demo state', () => {
const { demoStateSha256 } = loadManifest();
assert.equal(
sha256(demoStateBlock()),
demoStateSha256,
'the demo state changed after the screenshots were taken — regenerate them',
);
});
test('docs point readers at the manifest directory only', () => {
const { dir } = loadManifest();
const stray = [];
for (const rel of DOCS_POINTING_AT_SCREENSHOTS) {
const text = readFileSync(pluginRoot + rel, 'utf8');
for (const m of text.matchAll(/playground\/screenshots\/([^/\s`)]+)\//g)) {
if (`playground/screenshots/${m[1]}` !== dir) stray.push(`${rel}: ${m[0]}`);
}
}
assert.deepEqual(stray, []);
assert.match(readFileSync(pluginRoot + 'README.md', 'utf8'), new RegExp(`${dir}/`), 'README gallery must name the directory');
});