Files
houseplan-card/test/validate-gate.test.mjs
T
Claudeandclaude[bot] 1514899746 ci: имена job validate.yml — контракт, сверяемый с файлом; шарды из матрицы (#622)
- scripts/workflow-jobs.mjs: разбор job-уровня validate.yml (id, name,
  inline matrix) без зависимостей; незнакомые формы — громкая ошибка.
- ci-proof: JOB_RULES по id job с именем-контрактом; число и точные имена
  экземпляров — из матрицы YAML (константы count: 6 нет); resolveJobRules
  на каждом evaluateCiProof — переименование даёт failed с названной job;
  jobContractProblems — сверка в обе стороны, UNCONSUMED_JOBS для proof.
- validate-gate: MUTANT_JOB_PREFIX — из контракта ci-proof.
- e2e-gate: E2E_JOB_NAME — зеркало name: из houseplan-e2e e2e.yml
  (43899da5), распознавание выводится из шаблона; missing называет
  завершённые прогоны без job по контракту.
- тесты: фикстуры имён выводятся из validate.yml; новый
  test/workflow-jobs.test.mjs; 5 мутантов в реестре.

Issue: #622
User-Visible: no
2026-09-24 01:35:25 +00:00

214 lines
13 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #510 §5: the review pipeline proves mutants on the material before spending a review cycle.
import assert from 'node:assert/strict';
import test from 'node:test';
import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
const SHA = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
/** Fake gh: a scripted list of run snapshots per call, a virtual clock. */
// #622: имена и число экземпляров job — из validate.yml, не копией строк.
const WORKFLOW = validateJobs();
const jobsOf = (...ids) => ids.flatMap((id) => jobInstanceNames(WORKFLOW.get(id)).map((name) => ({ name, conclusion: 'success' })));
const MUTANT_JOBS = jobsOf('changed_mutants');
const OTHER_JOBS = jobsOf('frontend');
const BASE_JOBS = jobsOf('preflight', 'changes', 'reuse');
function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
let clock = 0;
let calls = 0;
const dispatched = [];
return {
ops: {
listRuns: async () => { const s = snapshots[Math.min(calls, snapshots.length - 1)]; calls += 1; return s; },
listRunsOnRef: async () => onRef,
jobs: async (id) => jobsById[id] ?? [...OTHER_JOBS, ...MUTANT_JOBS],
candidateTree: async () => TREE,
proof: async (row) => {
const selected = jobsById[row.databaseId] ?? [...OTHER_JOBS, ...MUTANT_JOBS];
const mutants = provesMutants(selected);
const proof = buildCiProof({
candidateSha: SHA, candidateTree: TREE, runId: row.databaseId,
attempt: row.attempt ?? 1, event: row.event,
needs: {
preflight: { result: 'success' },
changes: { result: 'success', outputs: {
heavy: 'false', mutants_requested: String(mutants),
frontend: 'true', backend: 'false', integration: 'false',
} },
reuse: { result: 'success', outputs: {} },
frontend: { result: 'success' },
changed_mutants: { result: mutants ? 'success' : 'skipped' },
},
});
return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() };
},
dispatch: async (ref) => { dispatched.push(ref); },
sleep: async (ms) => { clock += ms; },
now: () => clock,
},
dispatched,
calls: () => calls,
};
}
const run = (over) => ({ databaseId: 1, status: 'completed', conclusion: 'success', url: 'https://run/1', event: 'workflow_dispatch', headSha: SHA, ...over });
test('#510: only a dispatch run proves mutants; a push run on the same SHA does not', () => {
assert.equal(isMutantRun(run()), true);
assert.equal(isMutantRun(run({ event: 'push' })), false);
});
test('#510 (ревью ТЗ r1): proof needs the mutant jobs executed and green, not just a green run', () => {
assert.equal(provesMutants([...OTHER_JOBS, ...MUTANT_JOBS]), true);
assert.equal(provesMutants(OTHER_JOBS), false, 'no mutant job at all — mutants were not requested');
assert.equal(provesMutants([...OTHER_JOBS, ...MUTANT_JOBS.map((job) => ({ ...job, conclusion: 'skipped' }))]), false, 'skipped is not executed');
assert.equal(provesMutants([...MUTANT_JOBS.slice(0, 2), { ...MUTANT_JOBS[2], conclusion: 'failure' }]), false);
assert.equal(provesMutants([]), false);
});
test('#510 (ревью ТЗ r1): a green foreign dispatch whose mutant jobs were skipped is ignored — the gate dispatches its own', async () => {
const foreign = run({ databaseId: 5, url: 'https://run/foreign' });
const own = run({ databaseId: 6, url: 'https://run/own' });
const fake = fakeOps({
snapshots: [[foreign], [foreign], [own, foreign], [own, foreign]],
jobsById: { 5: [...OTHER_JOBS, ...MUTANT_JOBS.map((job) => ({ ...job, conclusion: 'skipped' }))] },
});
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/own');
assert.deepEqual(fake.dispatched, ['issue/1']);
});
test('#510 AC2: a completed green dispatch run on the material is accepted without a new dispatch', async () => {
const fake = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7 }), run()]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/1');
assert.deepEqual(fake.dispatched, []);
});
test('#510 AC2: a completed red dispatch run returns the task without review', async () => {
const fake = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops });
assert.equal(outcome.result, 'failed');
assert.equal(outcome.url, 'https://run/red');
});
test('#510 AC2: a green push run alone is not proof — the gate dispatches and waits', async () => {
const pushOnly = [run({ event: 'push', databaseId: 7 })];
const fake = fakeOps({ snapshots: [pushOnly, pushOnly, [...pushOnly, run({ status: 'in_progress', conclusion: null })], [...pushOnly, run()]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.deepEqual(fake.dispatched, ['issue/1'], 'exactly one dispatch on the branch');
});
test('#510 AC2: the dispatch that never appears is reported as missing, naming a moved material', async () => {
const fake = fakeOps({ snapshots: [[]], onRef: [run({ headSha: 'b'.repeat(40), url: 'https://run/other' })] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 1000 });
assert.equal(outcome.result, 'missing');
assert.equal(outcome.url, 'https://run/other');
assert.match(outcome.note, /ссылка ветки не указывает на материал/);
assert.match(outcome.note, /bbbbbbbb/);
// #539: прежде чем обвинять автора, гейт пробует ещё раз — своя гонка
// закрывается, чужой коммит переживает и вторую попытку.
assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1']);
});
// #539: `workflow_dispatch` принимает только ref, а имя ветки резолвится на
// стороне GitHub. Конвейер сам переписывает ветку ребейзом, и 12.09 на #536
// диспатч встал на ДОпушевый SHA — задача вернулась автору, которому чинить
// было нечего. Вторая попытка обязана попасть на материал.
test('#539: диспатч, промахнувшийся по устаревшей ссылке, повторяется и принимается', async () => {
const landed = run({ databaseId: 7, url: 'https://run/7' });
const fake = fakeOps({
// пусто → пусто (промах, повтор) → прогон на материале
snapshots: [[], [], [landed]],
onRef: [run({ headSha: 'b'.repeat(40), url: 'https://run/other' })],
});
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 6000 });
assert.equal(outcome.result, 'green', 'повторный диспатч попал на материал');
assert.equal(outcome.url, 'https://run/7');
assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1'], 'ровно две попытки, не бесконечно');
});
test('#510 AC2: a dispatch that never finishes is red after the total window', async () => {
const running = [run({ status: 'in_progress', conclusion: null })];
const fake = fakeOps({ snapshots: [running] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, totalMs: 10_000, pollMs: 4000 });
assert.equal(outcome.result, 'failed');
assert.match(outcome.note, /не завершился/);
assert.deepEqual(fake.dispatched, []);
});
test('#510: the tracked dispatch run is followed even when a newer dispatch appears first in the list', async () => {
const first = run({ databaseId: 1, status: 'in_progress', conclusion: null });
const newer = run({ databaseId: 2, status: 'in_progress', conclusion: null, url: 'https://run/2' });
const fake = fakeOps({ snapshots: [[first], [newer, first], [newer, run({ databaseId: 1, url: 'https://run/1' })]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/1');
});
test('#510 r1 M1: a cancelled dispatch proves nothing — the gate waits for its replacement instead of returning the task', async () => {
const cancelled = run({ databaseId: 1, conclusion: 'cancelled', url: 'https://run/cancelled' });
const replacement = run({ databaseId: 2, url: 'https://run/2' });
const fake = fakeOps({ snapshots: [[cancelled], [replacement, cancelled]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/2');
assert.deepEqual(fake.dispatched, [], 'the replacement was already there — no extra dispatch');
});
test('#510 r1 M1: a cancelled dispatch with no replacement gets one dispatch, not a red verdict', async () => {
const cancelled = run({ databaseId: 1, conclusion: 'cancelled' });
const fake = fakeOps({ snapshots: [[cancelled], [cancelled], [run({ databaseId: 3, url: 'https://run/3' }), cancelled]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.deepEqual(fake.dispatched, ['issue/1']);
});
// #636: раннер конвейера не ждёт Validate внутри job. С `wait: false` гейт
// возвращает завершённый прогон как раньше, а идущий — `pending`, не поллит его;
// прогон, который ещё не появился, гейт всё же диспатчит и дожидается его
// появления на материале (#539), потому что иначе событию завершения нечего
// будить.
test('#636: без ожидания завершённый зелёный dispatch принимается сразу, как и красный', async () => {
const green = fakeOps({ snapshots: [[run()]] });
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: green.ops, wait: false })).result, 'green');
const red = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] });
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: red.ops, wait: false })).result, 'failed');
assert.deepEqual(green.dispatched, []);
});
test('#636: идущий dispatch на материале — pending с его id и url, без единого sleep', async () => {
const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null, url: 'https://run/live', databaseId: 42 })]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, wait: false, pollMs: 1000 });
assert.equal(outcome.result, 'pending');
assert.equal(outcome.runId, 42);
assert.equal(outcome.url, 'https://run/live');
assert.equal(fake.ops.now(), 0, 'гейт не спал');
assert.deepEqual(fake.dispatched, []);
});
test('#636: без прогона гейт диспатчит, ждёт появления и возвращает pending, не завершение', async () => {
const pushOnly = [run({ event: 'push', databaseId: 7 })];
const live = [...pushOnly, run({ status: 'queued', conclusion: null, databaseId: 9 })];
const fake = fakeOps({ snapshots: [pushOnly, pushOnly, live, [...pushOnly, run({ databaseId: 9 })]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, wait: false, pollMs: 1000 });
assert.equal(outcome.result, 'pending');
assert.equal(outcome.runId, 9);
assert.deepEqual(fake.dispatched, ['issue/1']);
assert.equal(fake.calls(), 3, 'остановился на первом снимке с прогоном, до его завершения не дошёл');
});
test('#636: с ожиданием (умолчание) поведение прежнее — идущий прогон дожидается', async () => {
const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null })], [run()]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.ok(fake.ops.now() > 0, 'один poll прошёл');
});