mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
build: корпус отпечатка — только файлы, способные изменить кадр
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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 был бы пуст по смыслу');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user