Files
houseplan-card/test/gate-reuse.test.mjs
T
Matysh fbbea47545 ci: name the commit that actually broke golden
Упавший тяжёлый гейт не пишет маркер переиспользования — и правильно, иначе
починка осталась бы незамеченной. Но следствие в том, что следующий коммит
гонит ту же job на тех же входах, падает так же, и письмо «Run failed»
называет его. 29 августа так был назван 0f7b6f5, документ ревью, который не
может изменить ни одного пикселя: сцену без эталона добавил dbbe94ae.

Теперь падение оставляет второй маркер — с тем же ключом, что у маркера
успеха. Совпадение ключа доказывает равенство входов, поэтому повторное
падение может честно сказать «красная с такого-то SHA, этот коммит её не
ронял», а первое — «причина здесь». Само падение по-прежнему не кэшируется:
job прогоняется всегда, меняется только формулировка в notice и summary.

Первопричина записывается только на первом падении: иначе SHA съехал бы на
свидетеля и сообщение перевернулось бы смыслом.

Issue: #386
User-Visible: no
2026-08-30 11:23:35 +03:00

274 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HARNESS, JOBS, harnessFiles, inheritedFailureNote, parseFailureMarker, reuseKey,
} from '../scripts/gate-reuse.mjs';
/**
* Дерево, минимально достаточное для sourceFingerprint плюс оснастка каждой
* тяжёлой job. Реальные каталоги, а не подмены: ключ обязан отражать файловую
* систему так же, как в CI.
*/
const makeTree = () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-reuse-'));
const put = (rel, text) => {
const full = join(dir, rel);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, text);
};
put('package.json', '{"name":"x","version":"1.0.0"}\n');
put('package-lock.json', '{"lockfileVersion":3}\n');
put('rollup.config.mjs', 'export default {};\n');
put('tsconfig.json', '{}\n');
put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n');
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n");
put('demo/fixtures/one.mjs', 'export const fixture = 1;\n');
put('demo/smoke_alpha.mjs', 'console.log(1);\n');
put('demo/smoke_beta.mjs', 'console.log(2);\n');
put('demo/benchmark_glow.mjs', 'export const glow = 1;\n');
put('demo/golden/run.mjs', 'export const run = 1;\n');
put('demo/golden/baselines/one.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]));
put('demo/performance/compare.mjs', 'export const cmp = 1;\n');
put('tests_backend/test_pure.py', 'def test_x():\n assert True\n');
put('custom_components/houseplan/store.py', 'VERSION = 1\n');
put('custom_components/houseplan/frontend/houseplan-card.js', 'built bundle\n');
put('pytest.ini', '[pytest]\n');
put('docs/STATUS.md', 'status\n');
return { dir, put };
};
const keys = (dir) => Object.fromEntries(JOBS.map((job) => [job, reuseKey(dir, job)]));
test('every heavy job has a non-empty harness and its own key', () => {
const { dir } = makeTree();
try {
assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']);
for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job);
// Ключи различаются между job: иначе правка чужой оснастки гасила бы чужой
// прогон, а совпадение ключей маскировало бы это как «то же самое».
const set = new Set(Object.values(keys(dir)));
assert.equal(set.size, JOBS.length);
assert.throws(() => reuseKey(dir, 'frontend'), /неизвестная job/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('documentation, workflows and unit tests leave every key untouched (#208)', () => {
// Именно этот случай и оплачивал полный прогон: коммит, не меняющий ни одного
// входа поведения и ни одной оснастки.
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('docs/STATUS.md', 'status changed\n');
put('.github/workflows/validate.yml', 'name: Validate\n');
put('test/some.test.mjs', 'import test from "node:test";\n');
put('scripts/process-gate.mjs', '// unrelated tooling\n');
put('PROCESS.md', 'canon\n');
assert.deepEqual(keys(dir), before);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('a behaviour input changes every key, including a version bump (#208)', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n// behaviour\n");
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), before[job], job);
// Релизный кандидат бампает версию, поэтому его ключи заведомо новые и
// полный набор гейтов прогоняется всегда — переиспользование не может
// ослабить релизный гейт.
const bumped = keys(dir);
put('src/card.ts', "export const CARD_VERSION = '1.1.0';\n// behaviour\n");
put('package.json', '{"name":"x","version":"1.1.0"}\n');
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), bumped[job], job);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('harness edits are isolated to their own job (#208)', () => {
const { dir, put } = makeTree();
const only = (changed) => {
const before = keys(dir);
return (apply) => {
apply();
const after = keys(dir);
for (const job of JOBS) {
if (job === changed) assert.notEqual(after[job], before[job], `${job} должен меняться`);
else assert.equal(after[job], before[job], `${job} меняться не должен`);
}
};
};
try {
only('smoke')(() => put('demo/smoke_alpha.mjs', 'console.log(3);\n'));
// Эталон — вход сравнения, его подмена обязана менять ключ golden.
only('golden')(() => put('demo/golden/baselines/one.png',
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02])));
only('performance_smoke')(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n'));
only('backend')(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the built bundle is outside every key — each job builds it itself (#208)', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('custom_components/houseplan/frontend/houseplan-card.js', 'rebuilt bundle\n');
put('dist/houseplan-card.js', 'rebuilt bundle\n');
put('demo/srv/assets/houseplan-card.js', 'rebuilt bundle\n');
assert.deepEqual(keys(dir), before);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the key is canonical across line endings', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('demo/smoke_alpha.mjs', 'console.log(1);\r\n');
assert.deepEqual(keys(dir), before, 'CRLF не должен рождать другой ключ');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the CLI prints the key and writes it to GITHUB_OUTPUT', (t) => {
const { dir } = makeTree();
const cli = fileURLToPath(new URL('../scripts/gate-reuse.mjs', import.meta.url));
const out = join(dir, 'gh-output');
writeFileSync(out, '');
try {
const run = (args, env = {}) => spawnSync(process.execPath, [cli, ...args],
{ encoding: 'utf8', env: { ...process.env, ...env } });
const ok = run([`--repo=${dir}`, '--job=golden'], { GITHUB_OUTPUT: out });
assert.equal(ok.status, 0, ok.stdout + ok.stderr);
const printed = ok.stdout.trim();
assert.match(printed, /^[0-9a-f]{64}$/);
assert.equal(printed, reuseKey(dir, 'golden'));
// Значение уезжает в GITHUB_OUTPUT ровно в том виде, который читает шаг.
// Раньше здесь стоял динамический import внутри синхронной функции — CLI
// падал бы именно в CI, где GITHUB_OUTPUT задан.
assert.equal(readFileSync(out, 'utf8'), `key=${printed}\n`);
// Без --job работать нельзя: пустой ключ совпал бы со всем подряд.
assert.equal(run([`--repo=${dir}`]).status, 2);
const unknown = run([`--repo=${dir}`, '--job=frontend']);
assert.equal(unknown.status, 1);
assert.match(unknown.stderr, /неизвестная job/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('HARNESS keeps scripts/** out of the keys on purpose', () => {
// Инфраструктурная работа правит scripts/** постоянно. Если бы каталог
// целиком попал в ключ, переиспользование не срабатывало бы никогда — ровно
// тот случай, ради которого #208 и заводился.
const { dir, put } = makeTree();
try {
put('scripts/process-gate.mjs', '// tooling\n');
for (const [job, spec] of Object.entries(HARNESS)) {
assert.ok(!spec.roots.includes('scripts'), `${job}: scripts в корнях обхода`);
assert.ok(
!harnessFiles(dir, job).some((rel) => rel.startsWith('scripts/')),
`${job}: scripts попал в оснастку`,
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// --- маркер падения (#386) -------------------------------------------------
const GATE_REUSE = fileURLToPath(new URL('../scripts/gate-reuse.mjs', import.meta.url));
test('первое падение на этих входах называет виновником текущий коммит (#386)', () => {
const note = inheritedFailureNote({ job: 'golden', sha: 'dbbe94aeff00', runUrl: 'https://run/1', prior: null });
assert.equal(note.first, true);
assert.match(note.notice, /впервые/);
assert.match(note.notice, /dbbe94ae/);
assert.match(note.summary.join('\n'), /этого коммита/);
});
test('унаследованное падение снимает вину с текущего коммита (#386)', () => {
const note = inheritedFailureNote({
job: 'golden',
sha: '0f7b6f5aaaaa',
runUrl: 'https://run/2',
prior: { sha: 'dbbe94aeff00', runUrl: 'https://run/1' },
});
assert.equal(note.first, false);
// Оба SHA обязаны быть различимы: письмо называет второй, а причина в первом.
assert.match(note.notice, /dbbe94ae/);
assert.equal(note.notice.includes('0f7b6f5'), false,
'свидетель не должен фигурировать как причина');
assert.match(note.summary.join('\n'), /не ронял/);
assert.match(note.summary.join('\n'), /https:\/\/run\/1/);
});
test('маркер без SHA не выдумывает коммит (#386)', () => {
const note = inheritedFailureNote({ job: 'smoke', sha: 'abc1234567', runUrl: '', prior: { sha: '', runUrl: '' } });
assert.equal(note.first, false);
assert.match(note.summary.join('\n'), /не записан/);
assert.equal(/`[0-9a-f]{8}`/.test(note.summary.join('\n')), false);
});
test('разбор маркера переживает пустой и мусорный вход (#386)', () => {
assert.equal(parseFailureMarker(''), null);
assert.equal(parseFailureMarker(undefined), null);
assert.deepEqual(parseFailureMarker('golden упала\nSHA: abc1234\nпрогон: https://run/9\n'),
{ sha: 'abc1234', runUrl: 'https://run/9' });
assert.deepEqual(parseFailureMarker('мусор\n'), { sha: '', runUrl: '' });
});
test('CLI пишет маркер на первом падении и не трогает его на втором (#386)', () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-failnote-'));
const marker = join(dir, '.fail-marker');
const summary = join(dir, 'summary.md');
const run = (sha) => spawnSync(process.execPath, [
GATE_REUSE, '--job=golden', '--note', `--marker=${marker}`,
], {
encoding: 'utf8',
env: {
...process.env,
GITHUB_SHA: sha,
RUN_URL: `https://run/${sha}`,
GITHUB_STEP_SUMMARY: summary,
GITHUB_OUTPUT: join(dir, 'out.txt'),
},
});
try {
const first = run('dbbe94aeff00');
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /::notice::/);
assert.match(readFileSync(marker, 'utf8'), /SHA: dbbe94aeff00/);
const second = run('0f7b6f5aaaaa');
assert.equal(second.status, 0, second.stderr);
// Главное свойство: свидетель не переписывает первопричину.
assert.match(readFileSync(marker, 'utf8'), /SHA: dbbe94aeff00/);
assert.match(second.stdout, /не ронял/);
assert.match(readFileSync(join(dir, 'out.txt'), 'utf8'), /first=false/);
assert.match(readFileSync(summary, 'utf8'), /унаследованное падение/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});