From b428eacca6d9206464c49a2805af235667bde851 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 10:25:36 +0300 Subject: [PATCH] =?UTF-8?q?build:=20=D0=BA=D0=BE=D1=80=D0=BF=D1=83=D1=81?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D1=8B,=20=D1=81=D0=BF=D0=BE=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=B0=D0=B4=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accept.mjs копирует уже снятые PNG и пишет манифест, policy.mjs — чистые предикаты. Ни тот, ни другой в момент рендера не исполняется, но оба входили в корпус, и правка любого объявляла устаревшими бандл и манифест скриншотов. В #334 из-за этого правило приёмки пришлось вынести в scripts/ и вызывать обёрткой вместо того, чтобы положить туда, где ему место. Возражение «run.mjs импортирует policy.mjs, значит исключение протекает» снято в комментарии: оттуда берутся проверка аргументов, действительность манифеста и код возврата — байты кадра определяются аргументами браузера и подготовкой сцены. Исключение — список, а не фильтр по имени. Тест закрепляет обе стороны, и обратная важнее прямой: исключение, доехавшее до matrix, harness, run или фикстур, сделает несвежий бандл неотличимым от свежего. Коммит меняет значение отпечатков, поэтому в dev идёт вместе с пересборкой бандла и пересъёмкой скриншотов. Golden при этом не затронут: sourceFingerprint, записанный в baselines-index.json, не валидирует никто — manifestValid смотрит matrixVersion, chromium и полноту набора сцен. Issue: #344 User-Visible: no --- scripts/source-fingerprint.mjs | 50 ++++++++++++++++++++- test/source-fingerprint.test.mjs | 75 +++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) 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 был бы пуст по смыслу'); +});