test: the real waitValidate is exercised against a cancelled dispatch

Code review r2 (M1): the cancelled-run filter in merge-candidate's
waitValidate had no test or mutant — every test replaced ops.waitValidate
with a fake. realOps now takes an injectable `exec` (default: the same
spawnSync wrapper) so the real implementation runs against scripted
`gh run list` answers: a cancelled dispatch is skipped and its
replacement followed; a lone cancelled run ends in `missing`, never red.

Mutant: merge-trusts-cancelled-dispatch.

Issue: #510
User-Visible: no
This commit is contained in:
Codex
2026-09-09 18:45:44 +03:00
parent 8acbd333d8
commit 615181050b
3 changed files with 54 additions and 4 deletions
+4 -4
View File
@@ -101,9 +101,9 @@ const sh = (cmd, args, opts = {}) => {
return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() };
};
export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }) {
export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, exec = sh }) {
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
const git = (...args) => sh('git', args);
const git = (...args) => exec('git', args);
const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; };
return {
fetch: (...refs) => must(git('fetch', '-q', 'origin', ...refs), 'git fetch'),
@@ -131,14 +131,14 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) =
// новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт
// именно этот dispatch-прогон; push-прогон на том же SHA их не содержит.
dispatchValidate: (ref) => {
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`);
},
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
const started = now();
let runId = null;
while (now() - started < VALIDATE_TOTAL_MS) {
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']);
const r = exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']);
const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
// Отменённый прогон ничего не доказывает (#511): его заменил следующий
// dispatch на той же ветке — ждём его, а не красим кандидата.
+11
View File
@@ -8158,6 +8158,17 @@ const MUTANT_DEFINITIONS = [
replace: " if (false) { // mutant: cancelled counts as red",
}],
},
{
id: 'merge-trusts-cancelled-dispatch',
guard: 'node --test test/merge-candidate.test.mjs',
because: 'the real waitValidate must skip a dispatch cancelled by its replacement; reading it as red '
+ 'fails the merge candidate for nothing (#510 review r2 M1, #511)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');",
replace: " const runs = all.filter((x) => (!event || x.event === event)); // mutant: cancelled is red",
}],
},
{
id: 'review-trusts-push-run-without-mutants',
guard: 'node --test test/validate-gate.test.mjs',
+39
View File
@@ -226,3 +226,42 @@ test('на настоящем git: чистый ребейз с равным pat
rmSync(dir, { recursive: true, force: true });
}
});
// ---------- #510 code review r2 M1: the real waitValidate, not a fake ----------
/** gh scripted by call: each `gh run list` answer is the next snapshot. */
function scriptedExec(snapshots) {
let calls = 0;
return {
exec: (cmd, args) => {
if (cmd === 'gh' && args[0] === 'run' && args[1] === 'list') {
const s = snapshots[Math.min(calls, snapshots.length - 1)];
calls += 1;
return { status: 0, stdout: JSON.stringify(s), stderr: '' };
}
throw new Error(`unexpected ${cmd} ${args.join(' ')}`);
},
calls: () => calls,
};
}
test('#510 r2 M1: realOps.waitValidate ignores a cancelled dispatch and follows its replacement', async () => {
const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' };
const push = { databaseId: 2, status: 'completed', conclusion: 'success', url: 'https://run/2', event: 'push' };
const replacement = { databaseId: 3, status: 'completed', conclusion: 'success', url: 'https://run/3', event: 'workflow_dispatch' };
const gh = scriptedExec([[cancelled, push], [cancelled, push], [replacement, cancelled, push]]);
let clock = 0;
const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock });
const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' });
assert.deepEqual(r, { result: 'green', url: 'https://run/3' });
assert.equal(gh.calls(), 3, 'kept polling past the cancelled run instead of returning red on the first answer');
});
test('#510 r2 M1: realOps.waitValidate with only a cancelled dispatch reports missing after the appear window, never red', async () => {
const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' };
const gh = scriptedExec([[cancelled]]);
let clock = 0;
const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock });
const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' });
assert.equal(r.result, 'missing');
});