mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: keep the docs fingerprint blind to the product version
Issue: #245 User-Visible: no
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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="держится на зарегистрированной связи" '
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user