Files
houseplan-card/test/validate-gate.test.mjs
Claude 2a62ad5b95 process: derived artifacts are accepted on dev once per beta (#697)
The screenshot fingerprint and golden baselines stop being a tax on every
task branch:

- Task branches no longer commit docs/images/** or golden baselines. On a
  branch the screenshot freshness stays a preflight warning; the review
  prompt, REVIEWER.md and AUTHOR.md drop check-docs as a per-task gate.
- beta-derived.yml refreshes them on dev in one bot commit before the beta
  candidate: canonical docs capture + docs:accept --reviewed, golden from
  the golden-images artifact of a completed Validate on dev +
  golden:accept --reviewed. A changed frame or scene is accepted only when
  named in the inputs; undeclared differences refuse. Baseline commits carry
  Release: and Baseline-Reviewed:; the subject is not a candidate subject.
- classify-changes: the Release: trailer on an issue/* branch no longer
  switches on the heavy set. ci:full / ci:golden do: process-track emits
  full=true, the review gate dispatches Validate with full=true and does not
  accept a light proof.

Canon: PROCESS.md §3 п.13, §5.1, §8, §11.4; CONTRIBUTING.md.

Issue: #697
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 23:38:50 +03:00

259 lines
16 KiB
JavaScript
Executable File
Raw Permalink 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, proofCandidate, 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, { mutants = true, full = false } = {}) => { dispatched.push(`${mutants ? ref : `${ref}:light`}${full ? ':full' : ''}`); },
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 прошёл');
});
// #696: треки show/ship доказываются лёгким Validate — без мутантов по диффу.
test('#696: without mutants a completed green push run on the material is proof, no dispatch', async () => {
const fake = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, url: 'https://run/push' })]], jobsById: { 7: OTHER_JOBS } });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/push');
assert.deepEqual(fake.dispatched, []);
});
test('#696: without mutants a red push run returns the task, a push still running is not waited for', async () => {
const red = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, conclusion: 'failure', url: 'https://run/red' })]], jobsById: { 7: OTHER_JOBS } });
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: red.ops, mutants: false })).result, 'failed');
// Идущий push раунд не разбудит (resume слушает dispatch) — гейт диспатчит
// свой лёгкий прогон и ждёт уже его.
const running = [run({ event: 'push', databaseId: 7, status: 'in_progress', conclusion: null })];
const fake = fakeOps({ snapshots: [running, [...running, run({ databaseId: 8, status: 'in_progress', conclusion: null })]], jobsById: { 8: OTHER_JOBS } });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, wait: false, pollMs: 1000 });
assert.equal(outcome.result, 'pending');
assert.equal(outcome.runId, 8);
assert.deepEqual(fake.dispatched, ['issue/1:light'], 'the light dispatch asks for no mutants');
});
test('#696: a completed run beats a newer running dispatch only without mutants', async () => {
const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' });
const running = run({ databaseId: 9, status: 'in_progress', conclusion: null });
const light = fakeOps({ snapshots: [[running, done]], jobsById: { 7: OTHER_JOBS } });
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green');
assert.equal(proofCandidate(run({ event: 'push', status: 'in_progress' }), { mutants: false }), false);
assert.equal(proofCandidate(run({ event: 'push' }), { mutants: true }), false);
});
test('#697: ci:full/ci:golden — лёгкий push-прогон не доказательство, гейт диспатчит full=true', async () => {
const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' });
const dispatchedRun = run({ databaseId: 8, status: 'in_progress', conclusion: null });
const fake = fakeOps({ snapshots: [[done], [done], [done, dispatchedRun]], jobsById: { 7: OTHER_JOBS } });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, full: true, wait: false, pollMs: 1000 });
assert.equal(outcome.result, 'pending');
assert.equal(outcome.runId, 8);
assert.deepEqual(fake.dispatched, ['issue/1:light:full']);
// без метки тот же push-прогон — доказательство, и dispatch не нужен
const light = fakeOps({ snapshots: [[done]], jobsById: { 7: OTHER_JOBS } });
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green');
assert.deepEqual(light.dispatched, []);
});