From 9b812a4e0e6d499a34c74539e2a1f75ea83bc2c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:49:54 +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, значит исключение протекает» снято в комментарии: из policy.mjs он берёт проверку аргументов, действительность манифеста и код возврата — байты кадра определяются аргументами браузера и подготовкой сцены. Исключение — список, а не фильтр по имени: «всё, что похоже на инструмент» расползётся. Тест закрепляет обе стороны, и обратная важнее прямой: исключение, доехавшее до matrix, harness, run или фикстур, сделает несвежий бандл неотличимым от свежего. Два мутанта проверены руками — расширение списка на run.mjs и снятие фильтра целиком, — каждый убит. ВНИМАНИЕ: коммит меняет значение всех отпечатков, поэтому в dev он идёт только вместе с пересборкой бандла, пересъёмкой скриншотов и переприёмкой эталонов. Последовательность — в issue. Правка пиксельно нейтральна по построению, значит при переприёмке не должна разойтись ни одна сцена; расхождение означало бы, что исключение сделано неверно. Issue: #344 User-Visible: no --- scripts/source-fingerprint.mjs | 52 ++++++++++++++++++++- test/source-fingerprint.test.mjs | 77 +++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/scripts/source-fingerprint.mjs b/scripts/source-fingerprint.mjs index 8b55be00..36a63326 100644 --- a/scripts/source-fingerprint.mjs +++ b/scripts/source-fingerprint.mjs @@ -20,12 +20,55 @@ const BUILD_INPUTS = [ 'scripts/source-fingerprint.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` + * (действительность манифеста и код возврата) и имя файла манифеста. Ни одно из + * них не участвует в рендере: байты кадра определяются аргументами браузера и + * подготовкой сцены, то есть `run.mjs`, `harness.mjs`, `matrix.mjs` и + * фикстурами. Если в `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, @@ -47,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..22740f18 100644 --- a/test/source-fingerprint.test.mjs +++ b/test/source-fingerprint.test.mjs @@ -3,7 +3,9 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; 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 +137,76 @@ 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(new URL('..', import.meta.url).pathname.replace(/\/$/, '')); + 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 был бы пуст по смыслу'); +});