mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
- 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
214 lines
13 KiB
JavaScript
Executable File
214 lines
13 KiB
JavaScript
Executable File
// #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 прошёл');
|
||
});
|