fix: keep the docs fingerprint blind to the product version

Issue: #245
User-Visible: no
This commit is contained in:
Matysh
2026-08-22 19:02:11 +03:00
parent 99ceb1e57f
commit 4799c6be0c
6 changed files with 145 additions and 25 deletions
+7 -2
View File
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
@@ -179,7 +179,12 @@ const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
try {
const fingerprint = await assertFreshDemoBundle(page, ROOT);
// Свежесть бандла проверяется строго, вместе с версией: картинки обязаны
// приехать из бандла, собранного из ЭТОГО дерева. А в манифест пишется
// версионно-нечувствительный отпечаток (#245) — номер версии на скриншотах
// не виден, и требовать из-за него пересъёмки нечестно.
await assertFreshDemoBundle(page, ROOT);
const fingerprint = visualFingerprint(ROOT);
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
await prepareGoldenScenario(page, scenario);
+12 -12
View File
@@ -1,8 +1,8 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"sourceFingerprint": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"captureScriptSha256": "97224705298d164ba0b2bc52dcd3bc9cf9e620057705816106f8cdfe6e20503e",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
"view-desktop": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "f8cba93960e94371a22c6c1c8451cbaac0742fd0f50ae2050e5cd99bcf95adc4"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "d5f67be890d6ad6bd810e202c26d922154476bc962fc017f0f95fa4455ef7fe2"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
}
}
+5 -2
View File
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from './source-fingerprint.mjs';
import { visualFingerprint } from './source-fingerprint.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = process.argv.includes('--external');
@@ -129,7 +129,10 @@ if (!existsSync(manifestPath)) {
} else {
const manifest = JSON.parse(canonicalText(manifestPath));
if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture');
if (manifest.sourceFingerprint !== sourceFingerprint(ROOT))
// Версионно-нечувствительный отпечаток (#245): бамп версии не меняет ни одного
// пикселя, поэтому не обязан требовать пересъёмки — иначе каждый релизный
// коммит оставляет этот гейт красным.
if (manifest.sourceFingerprint !== visualFingerprint(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');
if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
+13
View File
@@ -122,6 +122,19 @@ export const MUTANTS = [
replace: ' const span = centre;',
}],
},
{
id: 'docs-fingerprint-sees-product-version',
guard: 'node --test --test-name-pattern="не трогает отпечаток скриншотов" '
+ 'test/source-fingerprint.test.mjs',
because: 'номер версии продукта на скриншотах не нарисован, и если отпечаток документации '
+ 'снова начнёт его видеть, каждый релизный коммит будет оставлять job docs красным — '
+ 'красный гейт, который «всегда такой», перестают читать (#245)',
patches: [{
file: 'scripts/source-fingerprint.mjs',
find: ' ? (text) => text.split(version).join(\'0.0.0-product-version\')',
replace: ' ? (text) => text',
}],
},
{
id: 'smoke-select-drops-registered-link',
guard: 'node --test --test-name-pattern="держится на зарегистрированной связи" '
+49 -7
View File
@@ -20,27 +20,69 @@ const BUILD_INPUTS = [
'scripts/source-fingerprint.mjs',
];
/** Stable digest of frontend sources plus the files that control their build. */
export const sourceFingerprint = (root = process.cwd()) => {
const sourceRoot = resolve(root, 'src');
const hash = createHash('sha256');
const fingerprintFiles = (root) => {
const deterministicFixtureInputs = ['demo/fixtures', 'demo/golden']
.map((name) => resolve(root, name))
.filter(existsSync)
.flatMap(sourceFiles)
.filter((file) => file.endsWith('.mjs'));
const files = [
...sourceFiles(sourceRoot),
return [
...sourceFiles(resolve(root, 'src')),
...deterministicFixtureInputs,
...BUILD_INPUTS.map((name) => resolve(root, name)).filter(existsSync),
].sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
};
const digest = (root, files, normalize) => {
const hash = createHash('sha256');
for (const file of files) {
hash.update(relative(root, file).replaceAll('\\', '/'));
hash.update('\0');
// Git-canonical text, independent of core.autocrlf. Otherwise the injected
// hash would make an otherwise identical Windows/Linux bundle differ.
hash.update(readFileSync(file, 'utf8').replace(/\r\n?/g, '\n'));
hash.update(normalize(readFileSync(file, 'utf8').replace(/\r\n?/g, '\n')));
hash.update('\0');
}
return hash.digest('hex');
};
/** Stable digest of frontend sources plus the files that control their build. */
export const sourceFingerprint = (root = process.cwd()) =>
digest(root, fingerprintFiles(root), (text) => text);
/** Номер версии продукта, как его знает package.json. */
const productVersion = (root) => {
const path = resolve(root, 'package.json');
if (!existsSync(path)) return '';
try {
const version = JSON.parse(readFileSync(path, 'utf8')).version;
return typeof version === 'string' ? version : '';
} catch {
return '';
}
};
/**
* Тот же корпус, но без номера версии продукта (#245).
*
* Зачем понадобился второй отпечаток. Релизный коммит бампает версию в трёх
* местах разом: `package.json`, `package-lock.json` и `CARD_VERSION` в
* `src/houseplan-card.ts`. Для бандла и для переиспользования гейтов это
* настоящее изменение — бандл действительно другой, и его нельзя считать
* свежим; отпечаток обязан ехать. А для скриншотов документации номер версии
* не значит ничего: он на них не нарисован. Общий отпечаток на два разных
* вопроса давал дефект, из-за которого КАЖДЫЙ релизный коммит оставлял job
* `docs` красным: пересъёмка шла до бампа, и записанное значение не совпадало
* с закоммиченным деревом.
*
* Нормализуется ровно строка версии продукта — не любое похожее число:
* версия зависимости в `package-lock.json` остаётся частью отпечатка, иначе
* обновление зависимости перестало бы требовать пересъёмки.
*/
export const visualFingerprint = (root = process.cwd()) => {
const version = productVersion(root);
const normalize = version
? (text) => text.split(version).join('0.0.0-product-version')
: (text) => text;
return digest(root, fingerprintFiles(root), normalize);
};
+59 -2
View File
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import test from 'node:test';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
import { sourceFingerprint, visualFingerprint } from '../scripts/source-fingerprint.mjs';
test('source fingerprint is stable across LF and CRLF checkouts', () => {
const directory = mkdtempSync(resolve(tmpdir(), 'houseplan-fingerprint-'));
@@ -56,3 +56,60 @@ test('source fingerprint includes deterministic visual fixtures and golden code'
rmSync(directory, { recursive: true, force: true });
}
});
// #245: два отпечатка отвечают на два разных вопроса. «Тот же ли это бандл» —
// версия входит, потому что бандл действительно другой. «Те же ли это
// скриншоты» — версия не входит, потому что на картинках её нет. Раньше вопрос
// был один, и каждый релизный коммит оставлял job docs красным.
const releaseFixture = () => {
const directory = mkdtempSync(resolve(tmpdir(), 'houseplan-fingerprint-release-'));
mkdirSync(resolve(directory, 'src'), { recursive: true });
writeFileSync(resolve(directory, 'src/houseplan-card.ts'),
"const CARD_VERSION = '1.66.0';\nexport const paint = () => 1;\n", 'utf8');
writeFileSync(resolve(directory, 'package.json'),
'{"name":"fixture","version":"1.66.0","dependencies":{"lit":"3.1.0"}}\n', 'utf8');
writeFileSync(resolve(directory, 'package-lock.json'),
'{"name":"fixture","version":"1.66.0","packages":{"":{"version":"1.66.0"},'
+ '"node_modules/lit":{"version":"3.1.0"}}}\n', 'utf8');
return directory;
};
const bumpVersion = (directory, from, to) => {
for (const file of ['src/houseplan-card.ts', 'package.json', 'package-lock.json']) {
const path = resolve(directory, file);
writeFileSync(path, readFileSync(path, 'utf8').split(from).join(to), 'utf8');
}
};
test('бамп версии продукта не трогает отпечаток скриншотов, но трогает отпечаток бандла (#245)', () => {
const directory = releaseFixture();
try {
const before = { bundle: sourceFingerprint(directory), visual: visualFingerprint(directory) };
bumpVersion(directory, '1.66.0', '1.67.0-beta.1');
assert.notEqual(sourceFingerprint(directory), before.bundle,
'бандл после бампа другой — отпечаток обязан измениться, иначе несвежий бандл сойдёт за свежий');
assert.equal(visualFingerprint(directory), before.visual,
'номер версии на скриншотах не нарисован: требовать пересъёмки из-за него нечестно');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('отпечаток скриншотов не слепнет к правкам src и версиям зависимостей (#245)', () => {
const directory = releaseFixture();
try {
const initial = visualFingerprint(directory);
const cardPath = resolve(directory, 'src/houseplan-card.ts');
writeFileSync(cardPath, readFileSync(cardPath, 'utf8').replace('paint = () => 1', 'paint = () => 2'), 'utf8');
const afterSource = visualFingerprint(directory);
assert.notEqual(afterSource, initial, 'правка src обязана требовать пересъёмки');
const lockPath = resolve(directory, 'package-lock.json');
writeFileSync(lockPath, readFileSync(lockPath, 'utf8').replace('"3.1.0"', '"3.2.0"'), 'utf8');
assert.notEqual(visualFingerprint(directory), afterSource,
'версия зависимости меняет рендер и обязана менять отпечаток — нормализуется только версия продукта');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});