diff --git a/scripts/source-fingerprint.mjs b/scripts/source-fingerprint.mjs index d05489bd..2380afa4 100644 --- a/scripts/source-fingerprint.mjs +++ b/scripts/source-fingerprint.mjs @@ -22,12 +22,53 @@ const BUILD_INPUTS = [ 'scripts/bundle-manifest.mjs', ]; +/** + * Файлы, которые исполняются ПОСЛЕ того, как картинка снята (#344). + * + * Критерий один и проверяется чтением: может ли правка этого файла изменить + * хотя бы один пиксель в кадре. `accept.mjs` копирует уже снятые PNG в каталог + * эталонов и пишет манифест; `policy.mjs` — чистые предикаты, вызываемые до и + * после съёмки. Ни тот, ни другой в момент рендера не исполняется вовсе. + * + * Почему это не косметика. Пока они входили в корпус, правка инструмента + * приёмки объявляла устаревшими сразу три вещи: закоммиченный бандл, манифест + * скриншотов документации и манифест эталонов. То есть каждая правка `accept.mjs` + * стоила пересборки и пересъёмки. В #334 из-за этого правило приёмки пришлось + * вынести в `scripts/` и вызывать обёрткой вместо того, чтобы положить его туда, + * где ему место. + * + * Список именно список, а не фильтр по имени: «всё, что похоже на инструмент» — + * правило, которое расползётся. Каждый пункт добавляется вручную и обязан + * выдерживать вопрос «а как эта правка попадёт в кадр». + * + * НЕ входят в этот список и входят в корпус: `matrix.mjs` (сцены, вьюпорт, + * тема, кадрирование), `harness.mjs` (состояние карточки перед съёмкой), + * `run.mjs` (аргументы браузера, опции скриншота, DPR) и все фикстуры + * (геометрия снимаемого плана). + * + * Возражение, которое здесь стоит снять заранее: `run.mjs` импортирует + * `policy.mjs`, а `run.mjs` в корпусе — значит ли это, что исключение протекает? + * Нет. Из `policy.mjs` он берёт `assertGoldenInvocation` (проверка аргументов, + * умеет только бросить), `goldenScenarioSetsMatch` и `goldenRunFailed` + * (действительность манифеста и код возврата) и имя файла манифеста. Ни одно из + * них не участвует в рендере: байты кадра определяются аргументами браузера и + * подготовкой сцены. Если в `policy.mjs` когда-нибудь появится что-то, + * влияющее на кадр, файл обязан вернуться в корпус. + */ +const POST_CAPTURE_INPUTS = new Set([ + 'demo/golden/accept.mjs', + 'demo/golden/policy.mjs', +]); + +const repoRelative = (root, file) => relative(root, file).replaceAll('\\', '/'); + const fingerprintFiles = (root) => { const deterministicFixtureInputs = ['demo/fixtures', 'demo/golden'] .map((name) => resolve(root, name)) .filter(existsSync) .flatMap(sourceFiles) - .filter((file) => file.endsWith('.mjs')); + .filter((file) => file.endsWith('.mjs')) + .filter((file) => !POST_CAPTURE_INPUTS.has(repoRelative(root, file))); return [ ...sourceFiles(resolve(root, 'src')), ...deterministicFixtureInputs, @@ -49,6 +90,13 @@ const digest = (root, files, normalize) => { return hash.digest('hex'); }; +/** Пути, исключённые из корпуса как исполняемые после съёмки (#344). */ +export const postCaptureInputs = () => [...POST_CAPTURE_INPUTS].sort(); + +/** Файлы корпуса отпечатка относительно корня — для тестов и диагностики. */ +export const fingerprintCorpus = (root = process.cwd()) => + fingerprintFiles(root).map((file) => repoRelative(root, file)); + /** Stable digest of frontend sources plus the files that control their build. */ export const sourceFingerprint = (root = process.cwd()) => digest(root, fingerprintFiles(root), (text) => text); diff --git a/test/source-fingerprint.test.mjs b/test/source-fingerprint.test.mjs index 54163469..97d15f47 100644 --- a/test/source-fingerprint.test.mjs +++ b/test/source-fingerprint.test.mjs @@ -2,8 +2,11 @@ import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import test from 'node:test'; -import { sourceFingerprint, visualFingerprint } from '../scripts/source-fingerprint.mjs'; +import { + fingerprintCorpus, postCaptureInputs, sourceFingerprint, visualFingerprint, +} from '../scripts/source-fingerprint.mjs'; test('source fingerprint is stable across LF and CRLF checkouts', () => { const directory = mkdtempSync(resolve(tmpdir(), 'houseplan-fingerprint-')); @@ -135,3 +138,73 @@ test('npm-скрипт не требует пересъёмки, а версия rmSync(directory, { recursive: true, force: true }); } }); + +// #344. Корпус отпечатка обязан состоять из файлов, способных изменить кадр. +// Пока в него входили `accept.mjs` и `policy.mjs`, правка инструмента приёмки +// объявляла устаревшими бандл и манифест скриншотов — и в #334 из-за этого +// правило приёмки пришлось вызывать обёрткой вместо того, чтобы положить его +// туда, где ему место. + +const goldenFixture = () => { + const directory = mkdtempSync(resolve(tmpdir(), 'houseplan-fingerprint-corpus-')); + mkdirSync(resolve(directory, 'src'), { recursive: true }); + mkdirSync(resolve(directory, 'demo/fixtures'), { recursive: true }); + mkdirSync(resolve(directory, 'demo/golden'), { recursive: true }); + writeFileSync(resolve(directory, 'src/example.ts'), 'export const value = 1;\n', 'utf8'); + writeFileSync(resolve(directory, 'demo/fixtures/plan.mjs'), 'export const fixture = 1;\n', 'utf8'); + for (const name of ['matrix', 'harness', 'run', 'accept', 'policy']) { + writeFileSync(resolve(directory, `demo/golden/${name}.mjs`), + `export const ${name} = 1;\n`, 'utf8'); + } + return directory; +}; + +const editGolden = (directory, name, value) => writeFileSync( + resolve(directory, `demo/golden/${name}.mjs`), `export const ${name} = ${value};\n`, 'utf8', +); + +test('правка инструмента приёмки не требует ни пересборки, ни пересъёмки (#344)', () => { + const directory = goldenFixture(); + try { + const before = { bundle: sourceFingerprint(directory), visual: visualFingerprint(directory) }; + editGolden(directory, 'accept', 2); + editGolden(directory, 'policy', 2); + assert.equal(sourceFingerprint(directory), before.bundle, + 'accept.mjs и policy.mjs исполняются после съёмки: бандл от их правки не устаревает'); + assert.equal(visualFingerprint(directory), before.visual, + 'ни одного пикселя они изменить не могут — пересъёмка была бы нечестной'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('файлы, определяющие кадр, из корпуса не выпали (#344)', () => { + const directory = goldenFixture(); + try { + // Обратная сторона важнее прямой: исключение, расползшееся на matrix, + // harness, run или фикстуры, сделает несвежий бандл неотличимым от свежего. + for (const name of ['matrix', 'harness', 'run']) { + const before = { bundle: sourceFingerprint(directory), visual: visualFingerprint(directory) }; + editGolden(directory, name, 2); + assert.notEqual(sourceFingerprint(directory), before.bundle, `${name}.mjs влияет на кадр`); + assert.notEqual(visualFingerprint(directory), before.visual, `${name}.mjs требует пересъёмки`); + } + const before = sourceFingerprint(directory); + writeFileSync(resolve(directory, 'demo/fixtures/plan.mjs'), 'export const fixture = 2;\n', 'utf8'); + assert.notEqual(sourceFingerprint(directory), before, 'фикстура — геометрия снимаемого плана'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('состав исключения объявлен списком, а не угадывается по имени (#344)', () => { + assert.deepEqual(postCaptureInputs(), ['demo/golden/accept.mjs', 'demo/golden/policy.mjs']); +}); + +test('в корпусе настоящего репозитория из demo/golden ровно три файла (#344)', () => { + const corpus = fingerprintCorpus(fileURLToPath(new URL('..', import.meta.url))); + const golden = corpus.filter((file) => file.startsWith('demo/golden/')).sort(); + assert.deepEqual(golden, ['demo/golden/harness.mjs', 'demo/golden/matrix.mjs', 'demo/golden/run.mjs']); + assert.ok(corpus.includes('scripts/source-fingerprint.mjs'), 'сам отпечаток остаётся входом сборки'); + assert.ok(corpus.some((file) => file.startsWith('src/')), 'корпус без src был бы пуст по смыслу'); +});