mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
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
259 lines
16 KiB
JavaScript
Executable File
259 lines
16 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, 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, []);
|
||
});
|