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:
parent
715950b8f9
commit
fa73e2d880
1 changed files with 97 additions and 0 deletions
97
tests/kb-update/test-screenshots-manifest.test.mjs
Normal file
97
tests/kb-update/test-screenshots-manifest.test.mjs
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue