docs: isolate screenshot tooling from release fixtures
Validate / process-gate (push) Failing after 4m52s
Validate / provenance (push) Successful in 4m55s
Validate / docs (push) Failing after 5m3s
Validate / changes (push) Successful in 4m39s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped

Issue: #35
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-16 01:22:55 +03:00
parent 88a647f6b2
commit 0253c4765c
17 changed files with 110 additions and 79 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
with: { node-version: 22 } with: { node-version: 22 }
- name: Validate public documentation - name: Validate public documentation
run: npm run docs:check -- --external run: node scripts/check-docs.mjs --external
provenance: provenance:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2 -2
View File
@@ -150,8 +150,8 @@ concurrent clients, but avoid editing the same object in two browsers at once.
may be replaced with fictional ones. may be replaced with fictional ones.
Documentation screenshots are produced by the reproducible Documentation screenshots are produced by the reproducible
`npm run docs:capture` command using synthetic data only. Scenario version, `npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
source fingerprint and every image hash are recorded in the source fingerprint and every image hash are recorded in the
[screenshot manifest](docs/images/manifest.json). [screenshot index](docs/images/screenshots.json).
License: [MIT](LICENSE). License: [MIT](LICENSE).
+2 -2
View File
@@ -154,8 +154,8 @@ default_floor: ground
воспроизведения; приватные entity ID можно заменить вымышленными. воспроизведения; приватные entity ID можно заменить вымышленными.
Скриншоты в документации получены воспроизводимой командой Скриншоты в документации получены воспроизводимой командой
`npm run docs:capture` только на синтетических данных. Версия сценариев, `npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
fingerprint исходников и хеш каждого изображения находятся в fingerprint исходников и хеш каждого изображения находятся в
[манифесте снимков](docs/images/manifest.json). [индексе снимков](docs/images/screenshots.json).
Лицензия: [MIT](LICENSE). Лицензия: [MIT](LICENSE).
File diff suppressed because one or more lines are too long
+72 -3
View File
@@ -102,6 +102,74 @@ const roomCardClip = (page) => page.evaluate(() => {
}; };
}); });
/**
* Documentation-only presentation state. Keep these mutations out of the
* golden harness: changing that release fixture would invalidate every visual
* baseline even though the production component and golden matrix are intact.
*/
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const card = window.__goldenCard;
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
}
if (current.roomMetrics) {
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
space.settings = {
...(space.settings || {}),
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
card._cfgEpoch += 1;
card._modelCache = null;
}
if (current.fixture === 'empty') {
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
card._cfgEpoch += 1;
card._modelCache = null;
card._space = '';
card._onboardingShown = true;
card.hass = { ...card.hass, floors: {} };
card._openSpaceDialog('create');
}
if (current.dialog === 'device-info') {
const device = card._devices.find((item) => item.id === current.deviceId);
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
card._infoCard = device;
}
card.requestUpdate();
await card.updateComplete;
await frame();
if (current.devicePresentationPreview) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const preview = dialog?.querySelector('hp-device-preview');
await preview?.updateComplete;
if (!body || !preview)
throw new Error('documentation device presentation preview is missing');
const bodyRect = body.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
body.scrollTop += previewRect.top - bodyRect.top - 180;
await frame();
const visibleBody = body.getBoundingClientRect();
const visiblePreview = preview.getBoundingClientRect();
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
throw new Error('documentation viewport does not show the device presentation preview');
}
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true }); mkdirSync(OUTPUT, { recursive: true });
copyFileSync(BUNDLE, DEMO_BUNDLE); copyFileSync(BUNDLE, DEMO_BUNDLE);
copyFileSync(BUNDLE, INTEGRATION_BUNDLE); copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
@@ -114,7 +182,8 @@ try {
const fingerprint = await assertFreshDemoBundle(page, ROOT); const fingerprint = await assertFreshDemoBundle(page, ROOT);
const scenarios = {}; const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) { for (const scenario of DOC_SCREENSHOTS) {
const runtime = await prepareGoldenScenario(page, scenario); await prepareGoldenScenario(page, scenario);
const runtime = await applyDocumentationState(page, scenario);
if (scenario.expectDialog && !runtime.dialog) if (scenario.expectDialog && !runtime.dialog)
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`); throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
const clip = scenario.capture === 'room-card' const clip = scenario.capture === 'room-card'
@@ -140,10 +209,10 @@ try {
fixture: 'synthetic-only', fixture: 'synthetic-only',
sourceFingerprint: fingerprint, sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)), captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run docs:capture', command: 'npm run build && node demo/docs/capture.mjs',
scenarios, scenarios,
}; };
writeFileSync(resolve(OUTPUT, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
} finally { } finally {
await browser.close(); await browser.close();
} }
+4 -41
View File
@@ -1,16 +1,7 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs'; import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs'; import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => { const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
if (name === 'large') return makeLargeHouseFixture();
if (name === 'empty') {
return {
config: { spaces: [], markers: [], settings: {} },
layout: {}, devices: {}, entities: {}, states: {}, areas: {},
};
}
return makeVisualMatrixFixture();
};
const themeVars = { const themeVars = {
dark: { dark: {
@@ -129,8 +120,7 @@ export function prepareGoldenFixture(scenario) {
fixture.devices[scenario.deviceId].name = scenario.deviceName; fixture.devices[scenario.deviceId].name = scenario.deviceName;
} }
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean' if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean' || typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|| scenario.roomMetrics) {
const space = requireSpace(); const space = requireSpace();
space.settings = { space.settings = {
...(space.settings || {}), ...(space.settings || {}),
@@ -139,9 +129,6 @@ export function prepareGoldenFixture(scenario) {
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}), ...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}), ...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}), ...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
...(scenario.roomMetrics ? {
label_temp: true, label_hum: true, label_lqi: true, label_light: true,
} : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}), ...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
}; };
} }
@@ -289,7 +276,7 @@ export async function prepareGoldenScenario(page, scenario) {
} }
const host = document.getElementById('host'); const host = document.getElementById('host');
const cardConfig = { const cardConfig = {
type: 'custom:houseplan-card', title: scenario.title || `Golden ${scenario.id}`, icon_size: 3.4, type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
language: scenario.language || 'en', language: scenario.language || 'en',
...(scenario.kiosk ? { kiosk: true } : {}), ...(scenario.kiosk ? { kiosk: true } : {}),
}; };
@@ -298,7 +285,7 @@ export async function prepareGoldenScenario(page, scenario) {
user: { id: 'golden', name: 'Golden fixture', is_admin: true }, user: { id: 'golden', name: 'Golden fixture', is_admin: true },
devices: fixture.devices || {}, entities: fixture.entities || {}, devices: fixture.devices || {}, entities: fixture.entities || {},
areas: fixture.areas || {}, states: fixture.states || {}, areas: fixture.areas || {}, states: fixture.states || {},
floors: scenario.noFloors ? {} : { floors: {
one: { floor_id: 'one', name: 'One', level: 0 }, one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 }, two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 }, three: { floor_id: 'three', name: 'Three', level: 2 },
@@ -551,21 +538,6 @@ export async function prepareGoldenScenario(page, scenario) {
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1) if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
throw new Error('golden viewport does not show the complete device light-source controls'); throw new Error('golden viewport does not show the complete device light-source controls');
} }
if (scenario.devicePresentationPreview) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const preview = dialog?.querySelector('hp-device-preview');
await preview?.updateComplete;
if (!body || !preview) throw new Error('golden device presentation preview is missing');
const bodyRect = body.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
body.scrollTop += previewRect.top - bodyRect.top - 180;
await frame();
const visibleBody = body.getBoundingClientRect();
const visiblePreview = preview.getBoundingClientRect();
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
throw new Error('golden viewport does not show the device presentation preview');
}
if (scenario.openHelp) { if (scenario.openHelp) {
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`); const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete; await help?.updateComplete;
@@ -585,15 +557,6 @@ export async function prepareGoldenScenario(page, scenario) {
await dialog?.updateComplete; await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus(); dialog?.renderRoot?.querySelector('.close')?.focus();
} }
} else if (scenario.dialog === 'device-info') {
const device = card._devices.find((item) => item.id === scenario.deviceId);
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
card._infoCard = device;
card.requestUpdate();
await card.updateComplete;
await frame();
if (!card.renderRoot.querySelector('hp-dialog'))
throw new Error(`golden device info did not open: ${scenario.deviceId}`);
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') { } else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
const full = scenario.dialog === 'backup-full'; const full = scenario.dialog === 'backup-full';
card._backupImportDialog = { card._backupImportDialog = {
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -250,9 +250,9 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
`~/.git-credentials`; pushes go over SSH with the `ha_jb` key. The old classic PAT `~/.git-credentials`; pushes go over SSH with the `ha_jb` key. The old classic PAT
expired and is gone. expired and is gone.
3. Privacy: legacy real-house plan sources (`assets/`) and screenshots were 3. Privacy: legacy real-house plan sources (`assets/`) and screenshots were
removed from the current tree. Public documentation images are generated removed from the current tree. Public documentation images are generated
from synthetic fixtures by `npm run docs:capture` and indexed in from synthetic fixtures by `npm run build && node demo/docs/capture.mjs` and indexed in
`docs/images/manifest.json`. Old images persist in git history and release `docs/images/screenshots.json`. Old images persist in git history and release
archives; history rewrite is deliberately not done because it would break archives; history rewrite is deliberately not done because it would break
release tags and HACS installs. release tags and HACS installs.
4. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers, 4. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

@@ -1,9 +1,9 @@
{ {
"version": 1, "version": 1,
"fixture": "synthetic-only", "fixture": "synthetic-only",
"sourceFingerprint": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceFingerprint": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"captureScriptSha256": "a0022c98553c5baae8335d7ba26f64129699c4ef6162dd1d88df9f70226ec404", "captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run docs:capture", "command": "npm run build && node demo/docs/capture.mjs",
"scenarios": { "scenarios": {
"view-desktop": { "view-desktop": {
"file": "01-view-desktop.png", "file": "01-view-desktop.png",
@@ -13,7 +13,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0" "imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
}, },
"view-touch": { "view-touch": {
@@ -24,7 +24,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249" "imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
}, },
"space-create": { "space-create": {
@@ -35,7 +35,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b" "imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
}, },
"room-contour-close": { "room-contour-close": {
@@ -46,8 +46,8 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "59fe5b9df034d255840e7ae6984efb675d39e84ad80dee6f9ad9394c54fb9cda" "imageSha256": "2f4770869f04c8d7f1e8ff33af1e8bd79f45ad6d97be6d5e6135b3a82fec9e4c"
}, },
"plan-context-tray": { "plan-context-tray": {
"file": "05-plan-context-tray.png", "file": "05-plan-context-tray.png",
@@ -57,8 +57,8 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "997bba208897b219051131dc51254ee3c76ed9f73597fde33070ac8cefdcd745" "imageSha256": "47d8fc2ac1c14cb699e990ca9c00d89b9ac57f6cc76b40420ceb26b4d7f50df4"
}, },
"device-editor": { "device-editor": {
"file": "06-device-editor.png", "file": "06-device-editor.png",
@@ -68,8 +68,8 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b" "imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
}, },
"device-display-preview": { "device-display-preview": {
"file": "06-device-display-preview.png", "file": "06-device-display-preview.png",
@@ -79,8 +79,8 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167" "imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
}, },
"background-editor": { "background-editor": {
"file": "07-background-editor.png", "file": "07-background-editor.png",
@@ -90,7 +90,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d" "imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
}, },
"room-card": { "room-card": {
@@ -101,7 +101,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11" "imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
}, },
"device-info": { "device-info": {
@@ -112,8 +112,8 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", "sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "655873ba709a2a702fde533b33ef109343ce0c6ca85ff985fdd7c4d41c877b33" "imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
} }
} }
} }
-2
View File
@@ -20,8 +20,6 @@
"golden:verify": "node demo/golden/run.mjs --mode=verify", "golden:verify": "node demo/golden/run.mjs --mode=verify",
"continuity:screencast": "node demo/screencast_visual_continuity.mjs", "continuity:screencast": "node demo/screencast_visual_continuity.mjs",
"golden:accept": "node demo/golden/accept.mjs", "golden:accept": "node demo/golden/accept.mjs",
"docs:capture": "npm run build && node demo/docs/capture.mjs",
"docs:check": "node scripts/check-docs.mjs",
"release:check": "node scripts/release-prerelease.mjs --check", "release:check": "node scripts/release-prerelease.mjs --check",
"release:prerelease": "node scripts/release-prerelease.mjs", "release:prerelease": "node scripts/release-prerelease.mjs",
"prepare": "node scripts/install-hooks.mjs" "prepare": "node scripts/install-hooks.mjs"
+5 -4
View File
@@ -123,16 +123,17 @@ for (const relative of PUBLIC_DOCS.slice(0, 4)) {
} }
} }
const manifestPath = resolve(ROOT, 'docs/images/manifest.json'); const manifestPath = resolve(ROOT, 'docs/images/screenshots.json');
if (!existsSync(manifestPath)) { if (!existsSync(manifestPath)) {
errors.push('docs/images/manifest.json: missing screenshot manifest'); errors.push('docs/images/screenshots.json: missing screenshot index');
} else { } else {
const manifest = JSON.parse(canonicalText(manifestPath)); const manifest = JSON.parse(canonicalText(manifestPath));
if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture'); if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture');
if (manifest.sourceFingerprint !== sourceFingerprint(ROOT)) errors.push('screenshot source fingerprint is stale; run npm run docs:capture'); if (manifest.sourceFingerprint !== sourceFingerprint(ROOT))
errors.push('screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs');
const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs'); const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs');
if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath))) if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
errors.push('screenshot capture script changed; run npm run docs:capture'); errors.push('screenshot capture script changed; run npm run build && node demo/docs/capture.mjs');
const ids = Object.keys(manifest.scenarios || {}); const ids = Object.keys(manifest.scenarios || {});
if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort())) if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort()))
errors.push('screenshot manifest scenario set is incomplete'); errors.push('screenshot manifest scenario set is incomplete');