test(red): prove every id of round 4.5a red at ab6b32c, with a judge that is tested to go red

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-24 19:23:59 +02:00
commit 5476a36782
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 629 additions and 0 deletions

167
tests/fix-round-4-5a-red.sh Normal file
View file

@ -0,0 +1,167 @@
#!/usr/bin/env bash
# tests/fix-round-4-5a-red.sh
# The red proof of fix round 4-5a (brief: the last Success Criterion). Every
# id of the round has tests whose names start with "[id]"; each of them must
# fail on an assertion at the round's start point ab6b32c.
# 1. Clone the repo into a temp directory outside it (git clone --shared
# --no-checkout, then checkout --detach BASE, as
# lib/verification/red-proof.mjs does; nothing registers in
# .git/worktrees).
# 2. Replace the clone's tests/ with the working tree's whole tests/ (tar),
# helpers and fixtures included, so a new helper is no crash.
# 3. Run the round's eight test files there, only the tagged tests
# (--test-name-pattern per id), under tests/helpers/red-reporter.mjs, and
# let tests/helpers/red-judge.mjs decide (--pass-ids DJ: DJ's defect is a
# missing test, its sentences exist at ab6b32c).
# 4. DJ by mutation: A deletes the copy-through span in
# commands/trekreview.md, B deletes both spans in
# agents/review-coordinator.md; each span must match exactly once. Each
# run is judged with --mutation DJ; the file is restored with cp after.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (clone, checkout, node or git missing); never 0 on a setup failure.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
cd "$(dirname "$0")/.." || { echo "red script: SETUP FAIL - cannot cd to the repo root" >&2; exit 2; }
ROOT="$(pwd -P)"
BASE=ab6b32c9304534b77c7ca2b36e7c993682c9efde
IDS='#52 #47 #48 #46 #2 #26 #33 #1 #30 #28 R6 D67 #15 #12 #66 #102 #57 #67 #113 #111 #24 #23 F9 F4a F4b F4c WF CL ORD DJ'
FILES='tests/lib/fix-round-4-5a-prose.test.mjs tests/lib/verdict-gate.test.mjs tests/lib/park-attempt-files.test.mjs tests/lib/autonomy-gate.test.mjs tests/lib/arg-parser.test.mjs tests/scripts/trekreview-armb-workflow.test.mjs tests/lib/coordinator-contract.test.mjs tests/validators/next-session-prompt-validator.test.mjs'
JUDGE="$ROOT/tests/helpers/red-judge.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE NODE_TEST_CONTEXT CLAUDE_CODE_SESSION_ID
setup_fail() {
echo "red script: SETUP FAIL - $1" >&2
exit 2
}
command -v node >/dev/null 2>&1 || setup_fail "node is not on PATH"
command -v git >/dev/null 2>&1 || setup_fail "git is not on PATH"
command -v tar >/dev/null 2>&1 || setup_fail "tar is not on PATH"
[ -f "$JUDGE" ] || setup_fail "missing $JUDGE"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/fix-round-4-5a-red.XXXXXX")" || setup_fail "mktemp -d failed"
case "$TMP" in
''|/) setup_fail "mktemp -d gave an unusable directory '$TMP'" ;;
esac
TMP_REAL="$(cd "$TMP" && pwd -P)" || setup_fail "cannot enter $TMP"
case "$TMP_REAL/" in
"$ROOT"/*) rmdir "$TMP" 2>/dev/null; setup_fail "the temp directory $TMP_REAL is inside the repo" ;;
esac
trap 'rm -rf "$TMP"' EXIT
trap 'rm -rf "$TMP"; exit 2' INT TERM HUP
TREE="$TMP/tree"
# ---- 1. the start point ----
git clone --quiet --shared --no-checkout "$ROOT" "$TREE" || setup_fail "could not clone $ROOT"
git -C "$TREE" checkout --quiet --detach "$BASE" || setup_fail "could not check out $BASE"
# ---- 2. the working tree's whole tests/ ----
tar -cf "$TMP/tests.tar" tests || setup_fail "could not tar tests/"
rm -rf "$TREE/tests" || setup_fail "could not remove the start point's tests/"
tar -xf "$TMP/tests.tar" -C "$TREE" || setup_fail "could not copy tests/ into the start point"
for f in $FILES; do
[ -f "$TREE/$f" ] || setup_fail "missing test file $f"
done
for id in $IDS; do
printf '%s\n' "$id"
done > "$TMP/ids" || setup_fail "could not write $TMP/ids"
# run_tagged <results> <file...>: the tagged tests of the files, run in the
# start point under the red reporter. The positional parameters carry the
# name patterns (no arrays in bash 3.2).
run_tagged() {
out="$1"
shift
(cd "$TREE" && node --import ./tests/helpers/no-session-id.mjs --test \
--test-reporter=./tests/helpers/red-reporter.mjs \
--test-reporter-destination="$out" "$@") >/dev/null 2>"$out.stderr"
[ -s "$out" ] || { cat "$out.stderr" >&2; setup_fail "the test run wrote no results to $out"; }
}
# judge <args...>: the red judge; 2 (usage or input) is a setup failure.
judge() {
node "$JUDGE" "$@"
rc=$?
[ "$rc" -eq 2 ] && setup_fail "the red judge could not run (exit 2)"
return "$rc"
}
STATUS=0
# ---- 3. every id at the start point ----
set --
for id in $IDS; do
set -- "$@" "--test-name-pattern=\\[$id\\]"
done
for f in $FILES; do
set -- "$@" "$f"
done
echo "red script: the tagged tests at $BASE"
run_tagged "$TMP/base.jsonl" "$@"
judge --ids "$TMP/ids" --results "$TMP/base.jsonl" --pass-ids DJ || STATUS=1
# ---- 4. DJ under its two mutations ----
cat > "$TMP/mutate.mjs" <<'EOF'
// mutate.mjs <file> <A|B>: delete DJ's copy-through span(s); each span must
// match exactly once, else exit 1 and leave the file as it was.
import { readFileSync, writeFileSync } from 'node:fs';
const [file, which] = process.argv.slice(2);
const SPANS = {
A: [
[/, and pass each reviewer's\s+`### Declined to Judge` lines verbatim from its saved\s+`\{project_dir\}\/\.review-out-\{reviewer\}\.md`: the coordinator copies them into\s+`## Declined to Judge`, so nothing a reviewer set aside disappears\./g, '.'],
],
B: [
[/^- \*\*Reviewers' Declined to Judge lines\*\* \u2014 each reviewer's own\s+`### Declined to Judge` list, verbatim\. You copy them into\s+`## Declined to Judge` \(body section 9\); you do not rule on them\.\n/gm, ''],
[/, then every\s+line a reviewer wrote under its own `### Declined to Judge`, copied as\s+`\{reviewer\}: \{line\}` \(they are not findings: they stay out of the JSON\s+`declined_to_judge` array and do not change the verdict\)/g, ''],
],
};
if (!SPANS[which]) { console.error(`mutate: unknown mutation ${which}`); process.exit(1); }
let text = readFileSync(file, 'utf8');
for (const [re, by] of SPANS[which]) {
const n = (text.match(re) || []).length;
if (n !== 1) { console.error(`mutate ${which}: ${re.source.slice(0, 60)}... matched ${n} times in ${file}, not once`); process.exit(1); }
text = text.replace(re, by);
}
writeFileSync(file, text);
console.log(`mutate ${which}: ${SPANS[which].length} span(s) deleted in ${file}`);
EOF
[ -s "$TMP/mutate.mjs" ] || setup_fail "could not write $TMP/mutate.mjs"
# mutation <A|B> <repo-relative file> <what>: apply, run the DJ tests, judge, restore.
mutation() {
m="$1"
rel="$2"
echo "red script: DJ mutation $m ($3)"
cp "$TREE/$rel" "$TMP/mutation-$m.orig" || setup_fail "could not save $rel"
if node "$TMP/mutate.mjs" "$TREE/$rel" "$m"; then
run_tagged "$TMP/mutation-$m.jsonl" "--test-name-pattern=\\[DJ\\]" tests/lib/fix-round-4-5a-prose.test.mjs
judge --ids "$TMP/ids" --results "$TMP/mutation-$m.jsonl" --mutation DJ || STATUS=1
else
echo "broken: mutation-not-applied [DJ] $m on $rel"
STATUS=1
fi
cp "$TMP/mutation-$m.orig" "$TREE/$rel" || setup_fail "could not restore $rel"
}
mutation A commands/trekreview.md "the copy-through span in commands/trekreview.md deleted"
mutation B agents/review-coordinator.md "both spans in agents/review-coordinator.md deleted"
if [ "$STATUS" -eq 0 ]; then
echo "red script: ok - every id red at $BASE, DJ under both mutations"
else
echo "red script: FAIL - a rule is broken (see the broken: lines above)" >&2
fi
exit "$STATUS"

145
tests/helpers/red-judge.mjs Normal file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env node
// tests/helpers/red-judge.mjs
// The judge of the red script of fix round 4-5a (tests/fix-round-4-5a-red.sh).
// It reads the JSON lines tests/helpers/red-reporter.mjs wrote for a run at
// ab6b32c and decides whether every id of the round was seen red there: each
// tagged test (a name that starts with "[id]") failed on an assertion.
//
// node tests/helpers/red-judge.mjs --ids <file> --results <jsonl>
// [--pass-ids <id>[,<id>...]] [--mutation <id>]
//
// Plain run, one line per broken rule and exit 1 when:
// broken: no-test [id] a listed id has no test
// broken: passed [id] {name} a tagged test passed (unless its
// id is in --pass-ids)
// broken: skipped [id] {name} a tagged test was skipped
// broken: todo [id] {name} a tagged test is a todo
// broken: not-assertion [id] {name} ({code} {cname})
// it failed on anything but
// ERR_ASSERTION / AssertionError
// (a crash is not red)
// broken: untagged-failure {name} ({file}) a failing event with no tag (a
// file that failed to load)
// broken: unlisted-tag [tag] {name} a tag that is not in the list
// An aggregate (a suite or file event with failureType subtestsFailed: it
// failed only because a child failed) is not counted.
// --mutation <id>: only that id's tests are judged (plus untagged failures
// and unlisted tags); exit 1 with "broken: mutation-not-red [id]" unless at
// least one of them fails on an assertion, and a "broken:" line for any of
// them that fails otherwise, is skipped or is a todo. A passing one is fine.
// Otherwise exit 0, printing "red: [id] {n} test(s)" per id (and
// "pass: [id] {n} test(s) (--pass-ids)" for a passing --pass-ids id).
// Exit 2: a usage error or an unreadable input (never 0).
import { readFileSync } from 'node:fs';
/** Print a usage problem and exit 2. */
function usage(msg) {
process.stderr.write(`red-judge: ${msg}\n` +
'usage: red-judge.mjs --ids <file> --results <jsonl> [--pass-ids <id>[,<id>...]] [--mutation <id>]\n');
process.exit(2);
}
/** --flag value pairs; every flag takes one value. */
function parse(argv) {
const known = new Set(['--ids', '--results', '--pass-ids', '--mutation']);
const o = {};
for (let i = 0; i < argv.length; i += 2) {
if (!known.has(argv[i])) usage(`unknown argument ${argv[i]}`);
if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) usage(`${argv[i]} needs a value`);
o[argv[i]] = argv[i + 1];
}
if (!o['--ids'] || !o['--results']) usage('--ids and --results are required');
return o;
}
/** A file's text, or exit 2. */
function readText(path, what) {
try {
return readFileSync(path, 'utf8');
} catch (e) {
return usage(`cannot read the ${what} file ${path}: ${e.code || e.message}`);
}
}
/** The id a test name starts with, in brackets, or null. */
function tagOf(name) {
const m = /^\[([^\]\s]+)\]/.exec(String(name));
return m ? m[1] : null;
}
/** One line of output: whitespace collapsed. */
const one = (s) => String(s).replace(/\s+/g, ' ').trim();
const opts = parse(process.argv.slice(2));
const ids = readText(opts['--ids'], 'ids').split('\n').map((l) => l.trim()).filter(Boolean);
if (ids.length === 0) usage(`the ids file ${opts['--ids']} lists no id`);
const listed = new Set(ids);
const passIds = new Set((opts['--pass-ids'] || '').split(',').map((s) => s.trim()).filter(Boolean));
const mutation = opts['--mutation'] || null;
if (mutation && !listed.has(mutation)) usage(`--mutation ${mutation} is not in the ids file`);
const events = [];
readText(opts['--results'], 'results').split('\n').forEach((line, i) => {
if (!line.trim()) return;
try {
events.push(JSON.parse(line));
} catch {
usage(`line ${i + 1} of ${opts['--results']} is not JSON`);
}
});
const broken = [];
const seen = new Map();
const red = new Map();
const passed = new Map();
const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
for (const e of events) {
if (e.ok === false && e.failureType === 'subtestsFailed') continue;
const name = one(e.name);
const tag = tagOf(name);
if (!tag) {
if (e.ok === false) broken.push(`broken: untagged-failure ${name} (${e.file ?? 'no file'})`);
continue;
}
if (!listed.has(tag)) {
broken.push(`broken: unlisted-tag [${tag}] ${name}`);
continue;
}
if (mutation && tag !== mutation) continue;
bump(seen, tag);
if (e.skip) {
broken.push(`broken: skipped [${tag}] ${name}`);
} else if (e.todo) {
broken.push(`broken: todo [${tag}] ${name}`);
} else if (e.ok === true) {
if (mutation || passIds.has(tag)) bump(passed, tag);
else broken.push(`broken: passed [${tag}] ${name}`);
} else if (e.code === 'ERR_ASSERTION' && e.cname === 'AssertionError') {
bump(red, tag);
} else {
broken.push(`broken: not-assertion [${tag}] ${name} (${e.code ?? 'no code'} ${e.cname ?? 'no name'})`);
}
}
const judged = mutation ? [mutation] : ids;
for (const id of judged) {
if (mutation) {
if (!red.get(id)) broken.push(`broken: mutation-not-red [${id}]`);
} else if (!seen.get(id)) {
broken.push(`broken: no-test [${id}]`);
}
}
if (broken.length > 0) {
process.stdout.write(`${broken.join('\n')}\n`);
process.exit(1);
}
const out = [];
for (const id of judged) {
if (red.get(id)) out.push(`red: [${id}] ${red.get(id)} test(s)`);
if (!mutation && passed.get(id)) out.push(`pass: [${id}] ${passed.get(id)} test(s) (--pass-ids)`);
}
process.stdout.write(`${out.join('\n')}\n`);
process.exit(0);

View file

@ -0,0 +1,39 @@
// tests/helpers/red-reporter.mjs
// A node:test custom reporter for the red script of fix round 4-5a
// (tests/fix-round-4-5a-red.sh). It writes one JSON line per test:pass and
// test:fail event, for tests/helpers/red-judge.mjs to read:
// {name, file, nesting, ok, skip, todo, code, cname, failureType}
// `code` and `cname` come from the error's cause (the error node:test reports
// is its own ERR_TEST_FAILURE wrapper): ERR_ASSERTION / AssertionError for an
// assertion. A cause with no code (a thrown TypeError) gives the wrapper's
// code; a cause that is not an object (a file that failed to load, a suite
// whose subtests failed) gives a null `cname`. A pass gives null for both.
// `failureType` is node:test's own (testCodeFailure, subtestsFailed, ...).
// Use: node --test --test-reporter=./tests/helpers/red-reporter.mjs
// --test-reporter-destination=<file> <files>
/**
* @param {AsyncIterable<{type: string, data: any}>} source - node:test events
* @returns {AsyncGenerator<string>} one JSON line per pass or fail
*/
export default async function* redReporter(source) {
for await (const event of source) {
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue;
const d = event.data || {};
const error = d.details && d.details.error;
const cause = error && error.cause;
const causeObj = cause !== null && typeof cause === 'object' ? cause : null;
const line = {
name: d.name,
file: d.file ?? null,
nesting: d.nesting,
ok: event.type === 'test:pass',
skip: Boolean(d.skip),
todo: Boolean(d.todo),
code: causeObj && causeObj.code != null ? String(causeObj.code) : (error && error.code != null ? String(error.code) : null),
cname: causeObj && causeObj.name != null ? String(causeObj.name) : null,
failureType: error && error.failureType !== undefined ? error.failureType : null,
};
yield `${JSON.stringify(line)}\n`;
}
}

View file

@ -1437,3 +1437,49 @@ test('e2e script: it names each of the four CLIs it runs', () => {
assert.ok(text.includes(cli), `the e2e script does not run ${cli}`);
}
});
// --- red script: tests/fix-round-4-5a-red.sh --------------------------------
// The round's red proof (the brief's last criterion). Static pins only: the
// script runs itself as the step's Regression:. Its judge is tested to go red
// in tests/lib/red-judge.test.mjs. The 30 ids are the brief's (brief.md:134
// and the 4.5a rows), written here as a constant so the list in the script
// cannot shrink without this test noticing.
const RED_SCRIPT = 'tests/fix-round-4-5a-red.sh';
const RED_IDS = ['#52', '#47', '#48', '#46', '#2', '#26', '#33', '#1', '#30', '#28', 'R6', 'D67', '#15', '#12', '#66',
'#102', '#57', '#67', '#113', '#111', '#24', '#23', 'F9', 'F4a', 'F4b', 'F4c', 'WF', 'CL', 'ORD', 'DJ'];
test('red script: tests/fix-round-4-5a-red.sh exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
assert.ok(existsSync(join(ROOT, RED_SCRIPT)), `missing file ${RED_SCRIPT}`);
const buf = readFileSync(join(ROOT, RED_SCRIPT));
assert.equal(buf.findIndex((b) => b > 127), -1, 'the red script is not ASCII only');
const r = spawnSync('/bin/bash', ['-n', join(ROOT, RED_SCRIPT)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, r.stderr);
const text = buf.toString('utf8');
assert.match(text, /^set -u$/m, 'the red script does not set -u');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/, /^\s*[A-Za-z_]+=\(/m, /\$\{[A-Za-z_]+,,\}/]) {
assert.doesNotMatch(text, bash4, `the red script uses ${bash4}, which bash 3.2 lacks or the round forbids`);
}
});
test('red script: an EXIT trap removes $TMP, and INT, TERM and HUP remove it too', () => {
const text = read(RED_SCRIPT);
assert.match(text, /^TMP="\$\(mktemp -d [^\n]*\)"/m, 'the red script does not make $TMP with mktemp -d');
assert.match(text, /^trap 'rm -rf "\$TMP"' EXIT$/m, 'no EXIT trap removes $TMP');
assert.match(text, /^trap '[^'\n]*rm -rf "\$TMP"[^'\n]*' INT TERM HUP$/m, 'no INT/TERM/HUP trap removes $TMP');
});
test('red script: BASE is the full ab6b32c sha', () => {
const text = read(RED_SCRIPT);
const lines = text.split('\n').filter((l) => /^BASE=/.test(l));
assert.deepEqual(lines, ['BASE=ab6b32c9304534b77c7ca2b36e7c993682c9efde'], 'BASE is not the one full ab6b32c sha');
});
test('red script: the id list is the round\'s 30 ids, in the brief\'s order', () => {
const text = read(RED_SCRIPT);
const lines = text.split('\n').filter((l) => /^IDS=/.test(l));
assert.equal(lines.length, 1, `expected one IDS= line; found ${lines.length}`);
const m = lines[0].match(/^IDS='([^']*)'$/);
assert.ok(m, `the IDS= line is not one single-quoted list: ${lines[0]}`);
assert.deepEqual(m[1].trim().split(/\s+/), RED_IDS);
});

View file

@ -0,0 +1,232 @@
// 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);
});
});