mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Упавший тяжёлый гейт не пишет маркер переиспользования — и правильно, иначе починка осталась бы незамеченной. Но следствие в том, что следующий коммит гонит ту же job на тех же входах, падает так же, и письмо «Run failed» называет его. 29 августа так был назван0f7b6f5, документ ревью, который не может изменить ни одного пикселя: сцену без эталона добавилdbbe94ae. Теперь падение оставляет второй маркер — с тем же ключом, что у маркера успеха. Совпадение ключа доказывает равенство входов, поэтому повторное падение может честно сказать «красная с такого-то SHA, этот коммит её не ронял», а первое — «причина здесь». Само падение по-прежнему не кэшируется: job прогоняется всегда, меняется только формулировка в notice и summary. Первопричина записывается только на первом падении: иначе SHA съехал бы на свидетеля и сообщение перевернулось бы смыслом. Issue: #386 User-Visible: no
274 lines
12 KiB
JavaScript
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 });
|
|
}
|
|
});
|