diff --git a/demo/docs/capture.mjs b/demo/docs/capture.mjs index 3cbfb122..414fc39c 100644 --- a/demo/docs/capture.mjs +++ b/demo/docs/capture.mjs @@ -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); diff --git a/docs/images/screenshots.json b/docs/images/screenshots.json index 64bcfe70..00a9e583 100644 --- a/docs/images/screenshots.json +++ b/docs/images/screenshots.json @@ -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" } } diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index a494ab0a..2ca1a65e 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -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))) diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index be0d7b7b..1709a4ed 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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="держится на зарегистрированной связи" ' diff --git a/scripts/source-fingerprint.mjs b/scripts/source-fingerprint.mjs index f98b2af7..9b6e3a20 100644 --- a/scripts/source-fingerprint.mjs +++ b/scripts/source-fingerprint.mjs @@ -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); +}; diff --git a/test/source-fingerprint.test.mjs b/test/source-fingerprint.test.mjs index 5855cb63..5fc8f126 100644 --- a/test/source-fingerprint.test.mjs +++ b/test/source-fingerprint.test.mjs @@ -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 }); + } +});