voyage/tests/lib/red-judge.test.mjs
2026-10-24 19:23:59 +02:00

232 lines
12 KiB
JavaScript

// tests/lib/red-judge.test.mjs
// The red judge of fix round 4-5a (tests/helpers/red-judge.mjs) and its
// reporter (tests/helpers/red-reporter.mjs). The round's red script
// (tests/fix-round-4-5a-red.sh) runs every tagged test at ab6b32c and lets the
// judge decide; a judge that always exits 0 would prove nothing. So the judge
// is fed planted JSON lines with the tags [X1] and [X2] (never a real id) and
// must exit 1 on each broken rule, naming it on one line.
//
// The judge runs only as a CLI through spawnSync and is never imported: each
// test asserts the exit code and the line the judge prints, so every test
// fails on an assertion while the judge does not exist.
//
// Rule lines (one per broken rule, exit 1):
// broken: no-test [id] a listed id has no test
// broken: passed [id] {name} a tagged test passed
// broken: skipped [id] {name} a tagged test was skipped
// broken: todo [id] {name} a tagged test is a todo
// broken: not-assertion [id] {name} (...) it failed, but not on an assertion
// broken: untagged-failure {name} (...) a failing event with no tag
// broken: unlisted-tag [tag] {name} a tag that is not in the list
// broken: mutation-not-red [id] --mutation: no assertion failure
// All red: exit 0 and "red: [id] {n} test(s)" per id.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const JUDGE = join(ROOT, 'tests', 'helpers', 'red-judge.mjs');
const REPORTER = join(ROOT, 'tests', 'helpers', 'red-reporter.mjs');
/** A temp directory outside tests/, removed after `fn` returns. */
function inTmp(fn) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'red-judge-test-')));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** One planted reporter line. Defaults: a top-level test that failed on an assertion. */
function ev(name, over = {}) {
return {
name, file: '/planted/x.test.mjs', nesting: 0, ok: false, skip: false, todo: false,
code: 'ERR_ASSERTION', cname: 'AssertionError', failureType: 'testCodeFailure', ...over,
};
}
const passing = (name) => ev(name, { ok: true, code: null, cname: null, failureType: null });
/** Run the judge on an id list and planted events, with extra flags. */
function judge(ids, events, extra = []) {
return inTmp((dir) => {
const idsFile = join(dir, 'ids');
const results = join(dir, 'results.jsonl');
writeFileSync(idsFile, ids.join('\n') + '\n');
writeFileSync(results, events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : ''));
const r = spawnSync(process.execPath, [JUDGE, '--ids', idsFile, '--results', results, ...extra],
{ encoding: 'utf8', env: childEnv() });
return { status: r.status, out: `${r.stdout}${r.stderr}` };
});
}
test('red judge: a tagged test that passes -> exit 1, "broken: passed [X1]"', () => {
const r = judge(['X1'], [passing('[X1] holds')]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: passed \[X1\] \[X1\] holds$/m, r.out);
});
test('red judge: a tagged test that fails with ERR_MODULE_NOT_FOUND -> exit 1, "broken: not-assertion [X1]" (a crash is not red)', () => {
const r = judge(['X1'], [ev('[X1] loads', { code: 'ERR_MODULE_NOT_FOUND', cname: 'Error' })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: not-assertion \[X1\] \[X1\] loads \(ERR_MODULE_NOT_FOUND Error\)$/m, r.out);
});
test('red judge: a listed id with no test -> exit 1, "broken: no-test [X2]"', () => {
const r = judge(['X1', 'X2'], [ev('[X1] red')]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: no-test \[X2\]$/m, r.out);
assert.doesNotMatch(r.out, /^broken: .*\[X1\]/m, 'X1 is red and must not be named');
});
test('red judge: a tag that is not in the list -> exit 1, "broken: unlisted-tag [X2]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X2] stray')]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: unlisted-tag \[X2\] \[X2\] stray$/m, r.out);
});
test('red judge: an untagged failing event (a file that failed to load) -> exit 1, "broken: untagged-failure"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('x.test.mjs', { code: null, cname: null })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: untagged-failure x\.test\.mjs \(\/planted\/x\.test\.mjs\)$/m, r.out);
});
test('red judge: an untagged aggregate (failureType subtestsFailed) next to a tagged assertion failure is not counted -> exit 0', () => {
const r = judge(['X1'], [
ev('[X1] red', { nesting: 1 }),
ev('group', { code: 'ERR_TEST_FAILURE', cname: null, failureType: 'subtestsFailed' }),
]);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: a skipped tagged test -> exit 1, "broken: skipped [X1]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X1] later', { ok: true, skip: true, code: null, cname: null, failureType: null })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: skipped \[X1\] \[X1\] later$/m, r.out);
});
test('red judge: every id covered by assertion failures -> exit 0, one "red: [id] {n} test(s)" line per id', () => {
const r = judge(['X1', 'X2'], [ev('[X1] one'), ev('[X1] two'), ev('[X2] three'), passing('untagged keep')]);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 2 test\(s\)$/m, r.out);
assert.match(r.out, /^red: \[X2\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: --pass-ids X1 with [X1] passing -> exit 0, the other ids still red', () => {
const r = judge(['X1', 'X2'], [passing('[X1] holds'), ev('[X2] red')], ['--pass-ids', 'X1']);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X2\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: --pass-ids X1 exempts only X1: a passing [X2] next to a passing [X1] -> exit 1, "broken: passed [X2]"', () => {
const r = judge(['X1', 'X2'], [passing('[X1] holds'), passing('[X2] holds too')], ['--pass-ids', 'X1']);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: passed \[X2\] \[X2\] .+$/m, r.out);
assert.doesNotMatch(r.out, /^broken: .*\[X1\]/m, 'X1 is in --pass-ids and must not be named');
});
test('red judge: a todo tagged test next to a red one -> exit 1, "broken: todo [X1]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X1] someday', { todo: true })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: todo \[X1\] \[X1\] someday$/m, r.out);
});
test('red judge: --mutation X1 with no [X1] assertion failure -> exit 1, "broken: mutation-not-red [X1]"', () => {
const r = judge(['X1'], [passing('[X1] holds')], ['--mutation', 'X1']);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: mutation-not-red \[X1\]$/m, r.out);
});
test('red judge: --mutation X1 with one [X1] assertion failure and one [X1] pass -> exit 0, "red: [X1] 1 test(s)"', () => {
const r = judge(['X1'], [passing('[X1] holds'), ev('[X1] red')], ['--mutation', 'X1']);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: --mutation X1 with one [X1] assertion failure and one [X1] TypeError crash -> exit 1, "broken: not-assertion [X1]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X1] crash', { code: 'ERR_TEST_FAILURE', cname: 'TypeError' })], ['--mutation', 'X1']);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: not-assertion \[X1\] \[X1\] crash \(ERR_TEST_FAILURE TypeError\)$/m, r.out);
});
/** Write fixture test files into `dir`, run them under the red reporter, return the JSONL path. */
function report(dir, files) {
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
const out = join(dir, 'out.jsonl');
spawnSync(process.execPath, ['--test', `--test-reporter=${REPORTER}`, `--test-reporter-destination=${out}`,
...Object.keys(files)], { cwd: dir, encoding: 'utf8', env: childEnv({ NODE_TEST_CONTEXT: undefined }) });
assert.ok(existsSync(out), 'the red reporter wrote no results file');
return out;
}
/** The judge on a results file the reporter wrote. */
function judgeFile(dir, ids, results) {
writeFileSync(join(dir, 'ids'), ids.join('\n') + '\n');
const r = spawnSync(process.execPath, [JUDGE, '--ids', join(dir, 'ids'), '--results', results],
{ encoding: 'utf8', env: childEnv() });
return { status: r.status, out: `${r.stdout}${r.stderr}` };
}
const HEAD = "import { test } from 'node:test';\nimport { strict as assert } from 'node:assert';\n";
test('red reporter + judge: a passing [X1] test and an [X2] test that throws a TypeError, run by node --test -> exit 1', () => {
inTmp((dir) => {
const out = report(dir, {
'x1.test.mjs': `${HEAD}test('[X1] holds', () => { assert.equal(1, 1); });\n`,
'x2.test.mjs': `${HEAD}test('[X2] throws', () => { throw new TypeError('boom'); });\n`,
});
const r = judgeFile(dir, ['X1', 'X2'], out);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: passed \[X1\] \[X1\] holds$/m, r.out);
assert.match(r.out, /^broken: not-assertion \[X2\] \[X2\] throws \(ERR_TEST_FAILURE TypeError\)$/m, r.out);
});
});
test('red reporter + judge: an [X1] test that fails on assert.equal(1, 2) -> exit 0, and the line carries the reporter fields', () => {
inTmp((dir) => {
const out = report(dir, { 'x1.test.mjs': `${HEAD}test('[X1] red', () => { assert.equal(1, 2); });\n` });
const lines = readFileSync(out, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const x1 = lines.filter((l) => l.name === '[X1] red');
assert.equal(x1.length, 1, readFileSync(out, 'utf8'));
assert.deepEqual(Object.keys(x1[0]).sort(),
['cname', 'code', 'failureType', 'file', 'name', 'nesting', 'ok', 'skip', 'todo']);
assert.deepEqual([x1[0].ok, x1[0].code, x1[0].cname, x1[0].failureType, x1[0].nesting],
[false, 'ERR_ASSERTION', 'AssertionError', 'testCodeFailure', 0]);
assert.equal(x1[0].file, join(dir, 'x1.test.mjs'));
const r = judgeFile(dir, ['X1'], out);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
});
});
test('red reporter + judge: a { skip: true } and a { todo: true } [X1] test carry skip and todo in their lines -> exit 1', () => {
inTmp((dir) => {
const out = report(dir, {
'x1.test.mjs': `${HEAD}test('[X1] red', () => { assert.equal(1, 2); });\n`
+ `test('[X1] skipped', { skip: true }, () => {});\n`
+ `test('[X1] todo', { todo: true }, () => { assert.equal(1, 2); });\n`,
});
const lines = readFileSync(out, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const byName = (n) => lines.filter((l) => l.name === n);
assert.equal(byName('[X1] skipped').length, 1, readFileSync(out, 'utf8'));
assert.equal(byName('[X1] todo').length, 1, readFileSync(out, 'utf8'));
assert.deepEqual([byName('[X1] skipped')[0].skip, byName('[X1] skipped')[0].todo], [true, false]);
assert.deepEqual([byName('[X1] todo')[0].skip, byName('[X1] todo')[0].todo], [false, true]);
assert.deepEqual([byName('[X1] red')[0].skip, byName('[X1] red')[0].todo], [false, false]);
const r = judgeFile(dir, ['X1'], out);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: skipped \[X1\] \[X1\] skipped$/m, r.out);
assert.match(r.out, /^broken: todo \[X1\] \[X1\] todo$/m, r.out);
});
});