512 lines
27 KiB
JavaScript
512 lines
27 KiB
JavaScript
// tests/lib/red-script.test.mjs
|
|
// The shared red script (tests/helpers/red-round.sh) and its mutate helper
|
|
// (tests/helpers/red-mutate.mjs). A round's wrapper hands the script a base
|
|
// commit, the round's ids, the other rounds' ids, a mutation module and test
|
|
// files; the script clones the base outside the repo, replaces the clone's
|
|
// tests/ with the working tree's, runs every tagged test once
|
|
// (--test-name-pattern=^\[), lets tests/helpers/red-judge.mjs decide, then
|
|
// applies each named mutation, runs only that id's tests, judges and restores.
|
|
// A script that always says "ok" would prove nothing, so it is run here on a
|
|
// small fixture repo whose answer is known:
|
|
// - lib/one.mjs returns 0 at BASE and 1 at HEAD: [X1] is red at BASE only;
|
|
// - lib/two.mjs is right at both: [X2] passes at BASE and is red only under
|
|
// the fixture's mutations (so it is a pass-id);
|
|
// - tests/other.test.mjs holds a passing [Z9] (another round's id);
|
|
// - tests/stray.test.mjs holds a mistyped [X 1] and an unlisted [Q9].
|
|
// The fixture repo is seeded once in os.tmpdir() with the script and helpers
|
|
// copied from this file's own ROOT/tests/helpers, and copied per test.
|
|
//
|
|
// Every test first asserts that the script exists, so each fails on an
|
|
// assertion while it does not. The script and its runs need Node >= 20.14
|
|
// (--test-force-exit); on an older Node each test is skipped.
|
|
//
|
|
// The two round wrappers (tests/fix-round-4-5a-red.sh, tests/fix-round-4-5b-red.sh)
|
|
// are run from a scratch copy whose tests/helpers/red-round.sh is a stub that
|
|
// prints its arguments, so what each wrapper hands the shared script is seen
|
|
// without a clone. Their mutation modules are loaded with a dynamic import.
|
|
|
|
import { after, test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import {
|
|
cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { delimiter, dirname, join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { childEnv } from '../helpers/child-env.mjs';
|
|
import { pathWith } from '../helpers/bin-path.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const HELPERS = join(ROOT, 'tests', 'helpers');
|
|
const SCRIPT = join(HELPERS, 'red-round.sh');
|
|
const INSTRUMENTS = ['red-round.sh', 'red-mutate.mjs', 'red-judge.mjs', 'red-reporter.mjs', 'no-session-id.mjs'];
|
|
const CHILD_TIMEOUT = 120000;
|
|
|
|
const [NODE_MAJOR, NODE_MINOR] = process.versions.node.split('.').map(Number);
|
|
const nodeTooOld = NODE_MAJOR < 20 || (NODE_MAJOR === 20 && NODE_MINOR < 14);
|
|
|
|
const cleanup = [];
|
|
after(() => {
|
|
for (const d of cleanup) rmSync(d, { recursive: true, force: true });
|
|
});
|
|
|
|
/** A new real-path temp directory, removed in after(). */
|
|
function tmp(prefix) {
|
|
const d = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
cleanup.push(d);
|
|
return d;
|
|
}
|
|
|
|
/** The environment of a git call or a script run: no inherited git or test-runner context. */
|
|
const env = (over = {}) => childEnv({
|
|
GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, NODE_TEST_CONTEXT: undefined, ...over,
|
|
});
|
|
|
|
function git(cwd, ...args) {
|
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: env() });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
/** Every test starts here: the script and its mutate helper exist. */
|
|
function requireScript() {
|
|
assert.ok(existsSync(SCRIPT), `the shared red script ${SCRIPT} does not exist`);
|
|
assert.ok(existsSync(join(HELPERS, 'red-mutate.mjs')), 'tests/helpers/red-mutate.mjs does not exist');
|
|
}
|
|
|
|
const X_TEST = `import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { appendFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { one } from '../lib/one.mjs';
|
|
import { two } from '../lib/two.mjs';
|
|
if (process.env.RS_SEEN_FILE) appendFileSync(process.env.RS_SEEN_FILE, fileURLToPath(import.meta.url) + '\\n');
|
|
test('[X1] one is 1', () => { assert.equal(one(), 1); });
|
|
test('[X2] two is 2', () => { assert.equal(two(), 2); });
|
|
test('an untagged test', () => { assert.ok(true); });
|
|
`;
|
|
const OTHER_TEST = `import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
test('[Z9] another round test that passes', () => { assert.ok(true); });
|
|
`;
|
|
const STRAY_TEST = `import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
test('[X 1] a mistyped tag', () => { assert.equal(1, 2); });
|
|
test('[Q9] an unlisted tag', () => { assert.equal(1, 2); });
|
|
`;
|
|
const TWO = '// two: the second value\nexport function two() { return 2; } // two\n';
|
|
const MUTATIONS = `export const MUTATIONS = [
|
|
{ key: 'X2-A', id: 'X2', what: 'two returns 3', spans: [{ file: 'lib/two.mjs', find: 'return 2;', replace: 'return 3;' }] },
|
|
{ key: 'X2-B', id: 'X2', what: 'two returns 4 (finds return 2 only after X2-A was restored)', spans: [{ file: 'lib/two.mjs', find: /return 2;/g, replace: 'return 4;' }] },
|
|
];
|
|
`;
|
|
|
|
let seeded = null;
|
|
|
|
/** The fixture repo, seeded once: BASE (one() wrong), HEAD (one() right), helpers in the working tree. */
|
|
function seed() {
|
|
if (seeded) return seeded;
|
|
const dir = join(tmp('red-script-seed-'), 'repo');
|
|
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
|
|
git(dir, 'init', '-q');
|
|
git(dir, 'config', 'user.name', 'Test');
|
|
git(dir, 'config', 'user.email', 'test@example.invalid');
|
|
git(dir, 'config', 'commit.gpgsign', 'false');
|
|
git(dir, 'config', 'core.hooksPath', '');
|
|
writeFileSync(join(dir, 'lib', 'one.mjs'), 'export function one() { return 0; }\n');
|
|
writeFileSync(join(dir, 'lib', 'two.mjs'), TWO);
|
|
writeFileSync(join(dir, 'tests', 'x.test.mjs'), X_TEST);
|
|
writeFileSync(join(dir, 'tests', 'other.test.mjs'), OTHER_TEST);
|
|
writeFileSync(join(dir, 'tests', 'stray.test.mjs'), STRAY_TEST);
|
|
git(dir, 'add', 'lib', 'tests');
|
|
git(dir, 'commit', '-q', '-m', 'base: one() is wrong');
|
|
const base = git(dir, 'rev-parse', 'HEAD');
|
|
writeFileSync(join(dir, 'lib', 'one.mjs'), 'export function one() { return 1; }\n');
|
|
git(dir, 'add', 'lib/one.mjs');
|
|
git(dir, 'commit', '-q', '-m', 'head: one() is right');
|
|
for (const name of INSTRUMENTS) {
|
|
if (existsSync(join(HELPERS, name))) cpSync(join(HELPERS, name), join(dir, 'tests', 'helpers', name));
|
|
}
|
|
writeFileSync(join(dir, 'mutations.mjs'), MUTATIONS);
|
|
seeded = { dir, base };
|
|
return seeded;
|
|
}
|
|
|
|
/** A fresh copy of the seeded fixture repo. */
|
|
function fixture() {
|
|
const s = seed();
|
|
const dir = join(tmp('red-script-fx-'), 'repo');
|
|
cpSync(s.dir, dir, { recursive: true });
|
|
return { dir, base: s.base, script: join(dir, 'tests', 'helpers', 'red-round.sh'), mutate: join(dir, 'tests', 'helpers', 'red-mutate.mjs') };
|
|
}
|
|
|
|
/** The script on the fixture, from outside it. */
|
|
function run(fx, args, over = {}) {
|
|
const r = spawnSync('/bin/bash', [fx.script, ...args],
|
|
{ cwd: tmpdir(), encoding: 'utf8', timeout: CHILD_TIMEOUT, env: env(over) });
|
|
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr };
|
|
}
|
|
|
|
/** The usual arguments: BASE, ids X1 X2, other id Z9, the fixture's mutations, then the files. */
|
|
function args(fx, files, { ids = 'X1 X2', other = 'Z9', mutations = join(fx.dir, 'mutations.mjs') } = {}) {
|
|
return ['--base', fx.base, '--ids', ids, '--other-ids', other, '--mutations', mutations, ...files];
|
|
}
|
|
|
|
/** The mutate helper on the fixture. */
|
|
function mutate(fx, ...rest) {
|
|
const r = spawnSync(process.execPath, [fx.mutate, ...rest],
|
|
{ encoding: 'utf8', timeout: CHILD_TIMEOUT, env: env() });
|
|
return { status: r.status, out: `${r.stdout}${r.stderr}` };
|
|
}
|
|
|
|
/** Every instrument path on the script's node lines: --import, --test-reporter= and the script right after node. */
|
|
function instrumentScan(text) {
|
|
const found = [];
|
|
for (const line of text.replace(/\\\n/g, ' ').split('\n')) {
|
|
if (/^\s*#/.test(line) || !/(^|[\s;(&|])node\s/.test(line)) continue;
|
|
for (const m of line.matchAll(/(?:--import[=\s]+|--test-reporter=|\bnode\s+)("?)([^\s"']+\.mjs)\1/g)) found.push(m[2]);
|
|
}
|
|
return found;
|
|
}
|
|
const notFromWorkingTree = (paths) => paths.filter((p) => !p.startsWith('$ROOT/tests/helpers/'));
|
|
|
|
/** The script lines that call run_tagged (not its definition). */
|
|
const runLines = (text) => text.split('\n').filter((l) => /^\s*run_tagged\s/.test(l));
|
|
|
|
test('[RS] a test red at BASE and green at HEAD exits 0', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const r = run(fx, args(fx, ['tests/x.test.mjs']));
|
|
assert.equal(r.status, 0, r.out);
|
|
assert.match(r.out, /^red script: ok/m, r.out);
|
|
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
|
|
assert.match(r.out, /^pass: \[X2\] 1 test\(s\) \(--pass-ids\)$/m, r.out);
|
|
assert.equal((r.out.match(/^red: \[X2\] 1 test\(s\)$/gm) || []).length, 2, `both mutations X2-A and X2-B ran red (X2-B finds its span only after X2-A was restored)\n${r.out}`);
|
|
assert.doesNotMatch(r.out, /^broken:/m, r.out);
|
|
});
|
|
|
|
test('[RS] a test tagged with an other-round id is not judged', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const files = ['tests/x.test.mjs', 'tests/other.test.mjs'];
|
|
const r = run(fx, args(fx, files, { other: 'Z9' }));
|
|
assert.equal(r.status, 0, r.out);
|
|
assert.doesNotMatch(r.out, /\[Z9\]/, r.out);
|
|
const control = run(fx, args(fx, files, { other: 'Z8' }));
|
|
assert.equal(control.status, 1, `control: with Z9 not on --other-ids the run must fail\n${control.out}`);
|
|
assert.match(control.out, /^broken: unlisted-tag \[Z9\]/m, control.out);
|
|
});
|
|
|
|
test('[RS] a mutation that matches no span exits 1 and writes nothing', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const mod = join(fx.dir, 'nomatch.mjs');
|
|
writeFileSync(mod, `export const MUTATIONS = [
|
|
{ key: 'X2-none', id: 'X2', what: 'a span that is not there', spans: [{ file: 'lib/two.mjs', find: 'return 99;', replace: 'return 3;' }] },
|
|
{ key: 'X2-half', id: 'X2', what: 'one span matches, one does not', spans: [{ file: 'lib/one.mjs', find: 'return 1;', replace: 'return 5;' }, { file: 'lib/two.mjs', find: 'return 99;', replace: 'return 3;' }] },
|
|
];
|
|
`);
|
|
const r = run(fx, args(fx, ['tests/x.test.mjs'], { mutations: mod }));
|
|
assert.equal(r.status, 1, r.out);
|
|
assert.match(r.out, /^broken: mutation-not-applied \[X2\] X2-none$/m, r.out);
|
|
assert.doesNotMatch(r.out, /^red script: ok/m, r.out);
|
|
|
|
const oneBefore = readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8');
|
|
const twoBefore = readFileSync(join(fx.dir, 'lib', 'two.mjs'), 'utf8');
|
|
const backup = join(tmp('red-script-bk-'), 'backup');
|
|
const m = mutate(fx, 'apply', mod, 'X2-half', fx.dir, backup);
|
|
assert.equal(m.status, 1, m.out);
|
|
assert.equal(readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8'), oneBefore, 'the span that matched was written although another did not match');
|
|
assert.equal(readFileSync(join(fx.dir, 'lib', 'two.mjs'), 'utf8'), twoBefore);
|
|
assert.equal(existsSync(backup), false, 'a mutation that was not applied wrote a backup');
|
|
});
|
|
|
|
test('[RS] a span that matches twice exits 1 and writes nothing', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const mod = join(fx.dir, 'twice.mjs');
|
|
writeFileSync(mod, `export const MUTATIONS = [
|
|
{ key: 'X2-twice', id: 'X2', what: 'a comment that is there twice', spans: [{ file: 'lib/two.mjs', find: '// two', replace: '' }] },
|
|
{ key: 'X2-twice-re', id: 'X2', what: 'one span once, one twice', spans: [{ file: 'lib/one.mjs', find: 'return 1;', replace: 'return 5;' }, { file: 'lib/two.mjs', find: /\\/\\/ two/g, replace: '' }] },
|
|
];
|
|
`);
|
|
const r = run(fx, args(fx, ['tests/x.test.mjs'], { mutations: mod }));
|
|
assert.equal(r.status, 1, r.out);
|
|
assert.match(r.out, /^broken: mutation-not-applied \[X2\] X2-twice$/m, r.out);
|
|
assert.match(r.out, /^broken: mutation-not-applied \[X2\] X2-twice-re$/m, r.out);
|
|
|
|
const oneBefore = readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8');
|
|
const backup = join(tmp('red-script-bk-'), 'backup');
|
|
const m = mutate(fx, 'apply', mod, 'X2-twice-re', fx.dir, backup);
|
|
assert.equal(m.status, 1, m.out);
|
|
assert.equal(readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8'), oneBefore, 'the span that matched once was written although another matched twice');
|
|
assert.equal(readFileSync(join(fx.dir, 'lib', 'two.mjs'), 'utf8'), TWO);
|
|
assert.equal(existsSync(backup), false, 'a mutation that was not applied wrote a backup');
|
|
});
|
|
|
|
test('[RS] a base that is no commit exits 2', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const r = run(fx, ['--base', 'f'.repeat(40), '--ids', 'X1 X2', '--other-ids', 'Z9', '--mutations', join(fx.dir, 'mutations.mjs'), 'tests/x.test.mjs']);
|
|
assert.equal(r.status, 2, r.out);
|
|
assert.match(r.stderr, /^red script: setup failure: /m, r.out);
|
|
assert.doesNotMatch(r.out, /^red script: ok/m, r.out);
|
|
});
|
|
|
|
test('[RS] the clone is removed after the run', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const td = tmp('red-script-tmpdir-');
|
|
const seen = join(tmp('red-script-seen-'), 'seen');
|
|
const r = run(fx, args(fx, ['tests/x.test.mjs']), { TMPDIR: td, RS_SEEN_FILE: seen });
|
|
assert.equal(r.status, 0, r.out);
|
|
const paths = existsSync(seen) ? readFileSync(seen, 'utf8').split('\n').filter(Boolean) : [];
|
|
assert.ok(paths.length > 0, 'the test file never ran, so the clone was never seen');
|
|
for (const p of paths) {
|
|
assert.ok(p.startsWith(`${td}/`), `the test ran at ${p}, not in a clone under TMPDIR ${td}`);
|
|
assert.equal(existsSync(p), false, `${p} is still there after the run`);
|
|
}
|
|
assert.deepEqual(readdirSync(td), [], 'the temp directory still holds files after the run');
|
|
});
|
|
|
|
test('[RS] the instruments run from the working tree by absolute path', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const text = readFileSync(SCRIPT, 'utf8');
|
|
const paths = instrumentScan(text);
|
|
for (const name of ['no-session-id.mjs', 'red-reporter.mjs', 'red-judge.mjs', 'red-mutate.mjs']) {
|
|
assert.ok(paths.some((p) => p.endsWith(`/${name}`)), `no node line runs ${name}; found: ${paths.join(', ')}`);
|
|
}
|
|
assert.deepEqual(notFromWorkingTree(paths), [], 'an instrument is not run as $ROOT/tests/helpers/...');
|
|
});
|
|
|
|
test('[RS] the instrument scan reports a planted ./tests/helpers/ line', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const text = readFileSync(SCRIPT, 'utf8');
|
|
const planted = text.replace(/--test-reporter=("?)\$ROOT\/tests\/helpers\/red-reporter\.mjs\1/, '--test-reporter=./tests/helpers/red-reporter.mjs');
|
|
assert.notEqual(planted, text, 'control: the reporter path to plant over was not found');
|
|
assert.deepEqual(notFromWorkingTree(instrumentScan(planted)), ['./tests/helpers/red-reporter.mjs'],
|
|
'control: the scan does not report a planted relative reporter path');
|
|
});
|
|
|
|
test('[RS] --require-sha256 with a git that cannot create a SHA-256 repository exits 2 with a setup-failure line', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const real = pathWith(['git']);
|
|
cleanup.push(real.path);
|
|
const bin = tmp('red-script-fakegit-');
|
|
const fake = join(bin, 'git');
|
|
writeFileSync(fake, `#!/bin/sh
|
|
case " $* " in
|
|
*" init "*"--object-format=sha256"*) echo "fake git: no SHA-256 here" >&2; exit 128 ;;
|
|
esac
|
|
exec '${join(real.path, 'git')}' "$@"
|
|
`, { mode: 0o755 });
|
|
const PATH = `${bin}${delimiter}${process.env.PATH}`;
|
|
const probe = spawnSync(fake, ['init', '--quiet', '--object-format=sha256', join(bin, 'probe')], { encoding: 'utf8', env: env({ PATH }) });
|
|
assert.notEqual(probe.status, 0, 'control: the fake git created a SHA-256 repository');
|
|
assert.equal(spawnSync(fake, ['--version'], { encoding: 'utf8', env: env({ PATH }) }).status, 0, 'control: the fake git does not run the real one');
|
|
|
|
const r = run(fx, [...args(fx, ['tests/x.test.mjs']).slice(0, 8), '--require-sha256', 'tests/x.test.mjs'], { PATH });
|
|
assert.equal(r.status, 2, r.out);
|
|
assert.match(r.stderr, /^red script: setup failure: .*SHA-256/m, r.out);
|
|
assert.doesNotMatch(r.out, /^red script: ok/m, r.out);
|
|
});
|
|
|
|
test('[E23a] a mistyped [X 1] and an unlisted [Q9] are reported and the run exits 1', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const fx = fixture();
|
|
const r = run(fx, args(fx, ['tests/x.test.mjs', 'tests/stray.test.mjs']));
|
|
assert.equal(r.status, 1, r.out);
|
|
assert.match(r.out, /^broken: unreadable-tag \[X 1\] a mistyped tag$/m, r.out);
|
|
assert.match(r.out, /^broken: unlisted-tag \[Q9\] \[Q9\] an unlisted tag$/m, r.out);
|
|
});
|
|
|
|
test('[E23a] the base run selects every tag with one ^\\[ pattern', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const text = readFileSync(SCRIPT, 'utf8');
|
|
const base = runLines(text).filter((l) => l.includes('run_tagged "$TMP/base.jsonl"'));
|
|
assert.equal(base.length, 1, `not exactly one base-run line:\n${base.join('\n')}`);
|
|
assert.ok(base[0].includes(` '--test-name-pattern=^\\['`), `the base run does not select with '--test-name-pattern=^\\[':\n${base[0]}`);
|
|
assert.equal((base[0].match(/--test-name-pattern/g) || []).length, 1, `the base run carries more than one name pattern:\n${base[0]}`);
|
|
});
|
|
|
|
test('[E23b] the base run carries --test-timeout and --test-force-exit', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
|
requireScript();
|
|
const text = readFileSync(SCRIPT, 'utf8');
|
|
const lines = runLines(text);
|
|
assert.ok(lines.some((l) => l.includes('run_tagged "$TMP/base.jsonl"')), 'no base-run line');
|
|
assert.ok(lines.length >= 2, `the mutation run line is missing:\n${lines.join('\n')}`);
|
|
for (const l of lines) {
|
|
assert.match(l, / --test-timeout=[1-9]\d*(\s|$)/, `a run without --test-timeout:\n${l}`);
|
|
assert.match(l, / --test-force-exit(\s|$)/, `a run without --test-force-exit:\n${l}`);
|
|
}
|
|
assert.match(text, /^#.*Node >= 20\.14/m, 'the header does not say that Node >= 20.14 is needed');
|
|
});
|
|
|
|
// --- the round wrappers ------------------------------------------------------
|
|
// Expected values from the plan (Step 3) and the 4.5a brief, written out here.
|
|
|
|
const WRAP_A = {
|
|
rel: 'tests/fix-round-4-5a-red.sh',
|
|
base: 'ab6b32c9304534b77c7ca2b36e7c993682c9efde',
|
|
mutations: 'tests/fix-round-4-5a-mutations.mjs',
|
|
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'],
|
|
};
|
|
const WRAP_B = {
|
|
rel: 'tests/fix-round-4-5b-red.sh',
|
|
base: 'be0b631fed21942b6c0fdae570b58e4112f8242d',
|
|
mutations: 'tests/fix-round-4-5b-mutations.mjs',
|
|
files: ['tests/lib/verdict-gate.test.mjs', 'tests/lib/done-line.test.mjs', 'tests/lib/park-attempt-files.test.mjs',
|
|
'tests/scripts/trekreview-armb-workflow.test.mjs', 'tests/lib/coordinator-contract.test.mjs', 'tests/lib/red-judge.test.mjs',
|
|
'tests/lib/red-script.test.mjs', 'tests/lib/review-recurrence.test.mjs', 'tests/lib/arg-parser.test.mjs',
|
|
'tests/commands/trekendsession.test.mjs', 'tests/validators/next-session-prompt-validator.test.mjs',
|
|
'tests/lib/fix-round-4-5b-prose.test.mjs', 'tests/lib/fix-round-4-5a-prose.test.mjs', 'tests/scripts/release-check.test.mjs'],
|
|
};
|
|
const IDS_B = ['M6', 'M11', 'M4', 'm4', 'M3', 'M7', 'M7r', 'E22c', 'E22e', 'M5', 'S3', 'E20', 'M9', 'M2', 'K3', 'M1', 'M1r', 'E23a',
|
|
'E23b', 'E23d', 'E23f', 'M8', 'M10', 'DJ5', 'M13', 'K2', 'DJ1', 'DJ2', 'DJ4', 'DJ4r', 'E14', 'E16a', 'E17a', 'E17b', 'E18a', 'Da',
|
|
'Db', 'K4', 'ARG', 'PTR', 'RS'];
|
|
|
|
/** A wrapper's text; asserts first that it exists. */
|
|
function wrapperText(w) {
|
|
const p = join(ROOT, w.rel);
|
|
assert.ok(existsSync(p), `the wrapper ${w.rel} does not exist`);
|
|
return readFileSync(p, 'utf8');
|
|
}
|
|
|
|
/** The words of a wrapper's one NAME='...' line. */
|
|
function quotedList(text, name, rel) {
|
|
const lines = text.split('\n').filter((l) => l.startsWith(`${name}=`));
|
|
assert.equal(lines.length, 1, `expected one ${name}= line in ${rel}; found ${lines.length}`);
|
|
const m = lines[0].match(new RegExp(`^${name}='([^']*)'$`));
|
|
assert.ok(m, `the ${name}= line of ${rel} is not one single-quoted list: ${lines[0]}`);
|
|
return m[1].split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
/** A stand-in for the shared script: one ARG: line per argument, exit $RS_STUB_EXIT (default 0). */
|
|
const STUB = `#!/bin/bash
|
|
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
|
|
exit "\${RS_STUB_EXIT:-0}"
|
|
`;
|
|
|
|
/** A scratch root holding both wrappers and the stub as tests/helpers/red-round.sh. */
|
|
function stubRoot() {
|
|
const a = wrapperText(WRAP_A);
|
|
const b = wrapperText(WRAP_B);
|
|
const dir = join(tmp('red-script-wrap-'), 'root');
|
|
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
|
|
writeFileSync(join(dir, WRAP_A.rel), a);
|
|
writeFileSync(join(dir, WRAP_B.rel), b);
|
|
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
|
|
return dir;
|
|
}
|
|
|
|
/** A wrapper run from the scratch root: status, output and the arguments the stub saw. */
|
|
function runWrapper(dir, w, over = {}) {
|
|
const r = spawnSync('/bin/bash', [join(dir, w.rel)], { cwd: tmpdir(), encoding: 'utf8', timeout: CHILD_TIMEOUT, env: env(over) });
|
|
const argv = r.stdout.split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
|
|
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr, argv };
|
|
}
|
|
|
|
/** The stub's arguments as options (each with a value but --require-sha256) and files. */
|
|
function parseCall(argv) {
|
|
const call = { flags: [], files: [] };
|
|
let i = 0;
|
|
while (i < argv.length && argv[i].startsWith('--')) {
|
|
if (argv[i] === '--require-sha256') {
|
|
call.flags.push(argv[i]);
|
|
i += 1;
|
|
} else {
|
|
call[argv[i]] = argv[i + 1];
|
|
i += 2;
|
|
}
|
|
}
|
|
call.files = argv.slice(i);
|
|
return call;
|
|
}
|
|
|
|
test('[RS] both wrappers exec the shared script with their own BASE, IDS and FILES', () => {
|
|
const dir = stubRoot();
|
|
for (const w of [WRAP_A, WRAP_B]) {
|
|
const text = wrapperText(w);
|
|
assert.deepEqual(text.split('\n').filter((l) => /^BASE=/.test(l)), [`BASE=${w.base}`], `${w.rel}: BASE is not the one full ${w.base.slice(0, 7)} sha`);
|
|
assert.deepEqual(quotedList(text, 'FILES', w.rel), w.files, `${w.rel}: the FILES= line is not the round's files`);
|
|
const ids = quotedList(text, 'IDS', w.rel);
|
|
const r = runWrapper(dir, w);
|
|
assert.equal(r.status, 0, `${w.rel} did not run the shared script:\n${r.out}`);
|
|
assert.ok(r.argv.length > 0, `${w.rel} handed the shared script no arguments:\n${r.out}`);
|
|
const call = parseCall(r.argv);
|
|
assert.equal(call['--base'], w.base, `${w.rel}: --base\n${r.out}`);
|
|
assert.deepEqual((call['--ids'] || '').split(/\s+/).filter(Boolean), ids, `${w.rel}: --ids is not its IDS= line\n${r.out}`);
|
|
assert.equal(call['--mutations'], join(dir, w.mutations), `${w.rel}: --mutations is not its own module by absolute path\n${r.out}`);
|
|
assert.deepEqual(call.files, w.files, `${w.rel}: the test files are not its FILES= line\n${r.out}`);
|
|
const failed = runWrapper(dir, w, { RS_STUB_EXIT: '1' });
|
|
assert.equal(failed.status, 1, `${w.rel} does not end with the shared script's exit status:\n${failed.out}`);
|
|
}
|
|
});
|
|
|
|
test('[RS] each wrapper reads the other round\'s ids from its IDS line, no copied list', () => {
|
|
const dir = stubRoot();
|
|
for (const [w, other] of [[WRAP_A, WRAP_B], [WRAP_B, WRAP_A]]) {
|
|
const otherText = wrapperText(other);
|
|
const otherIds = quotedList(otherText, 'IDS', other.rel);
|
|
const r = runWrapper(dir, w);
|
|
assert.equal(r.status, 0, r.out);
|
|
assert.deepEqual((parseCall(r.argv)['--other-ids'] || '').split(/\s+/).filter(Boolean), otherIds,
|
|
`${w.rel}: --other-ids is not the IDS= line of ${other.rel}\n${r.out}`);
|
|
|
|
const otherLine = otherText.split('\n').find((l) => l.startsWith('IDS='));
|
|
writeFileSync(join(dir, other.rel), otherText.replace(otherLine, "IDS='P1 P2'"));
|
|
const planted = runWrapper(dir, w);
|
|
assert.equal(planted.status, 0, planted.out);
|
|
assert.equal(parseCall(planted.argv)['--other-ids'], 'P1 P2', `${w.rel} does not read ${other.rel}'s IDS= line when it runs\n${planted.out}`);
|
|
|
|
writeFileSync(join(dir, other.rel), otherText.replace(otherLine, "IDS=''"));
|
|
const empty = runWrapper(dir, w);
|
|
assert.equal(empty.status, 2, `${w.rel}: an empty IDS= line in ${other.rel} is not a setup failure\n${empty.out}`);
|
|
assert.deepEqual(empty.argv, [], `${w.rel} ran the shared script with no other-round ids\n${empty.out}`);
|
|
assert.match(empty.stderr, /setup failure/, empty.out);
|
|
writeFileSync(join(dir, other.rel), otherText);
|
|
|
|
assert.ok(!wrapperText(w).includes(otherIds.join(' ')), `${w.rel} holds a copy of ${other.rel}'s id list`);
|
|
}
|
|
});
|
|
|
|
test('[RS] the 4.5b IDS line holds exactly the 41 ids', () => {
|
|
const ids = quotedList(wrapperText(WRAP_B), 'IDS', WRAP_B.rel);
|
|
assert.equal(IDS_B.length, 41, 'control: the expected list is not 41 ids');
|
|
assert.deepEqual(ids, IDS_B);
|
|
assert.equal(new Set(ids).size, ids.length, 'an id is on the 4.5b IDS= line twice');
|
|
});
|
|
|
|
test('[RS] only the 4.5b wrapper requires SHA-256', () => {
|
|
const dir = stubRoot();
|
|
const a = runWrapper(dir, WRAP_A);
|
|
const b = runWrapper(dir, WRAP_B);
|
|
assert.equal(a.status, 0, a.out);
|
|
assert.equal(b.status, 0, b.out);
|
|
assert.deepEqual(parseCall(a.argv).flags, [], `${WRAP_A.rel} passes a flag it should not:\n${a.out}`);
|
|
assert.deepEqual(parseCall(b.argv).flags, ['--require-sha256'], `${WRAP_B.rel} does not pass --require-sha256:\n${b.out}`);
|
|
});
|
|
|
|
test('[RS] every mutation module loads, keys are unique and every id is on its wrapper\'s IDS line', async () => {
|
|
const expectKeys = { [WRAP_A.rel]: ['DJ-A', 'DJ-B'], [WRAP_B.rel]: ['E23a', 'E23b', 'RS', 'E23d', 'E23f'] };
|
|
for (const w of [WRAP_A, WRAP_B]) {
|
|
const ids = quotedList(wrapperText(w), 'IDS', w.rel);
|
|
const p = join(ROOT, w.mutations);
|
|
assert.ok(existsSync(p), `the mutation module ${w.mutations} does not exist`);
|
|
const mod = await import(pathToFileURL(p).href);
|
|
assert.ok(Array.isArray(mod.MUTATIONS) && mod.MUTATIONS.length > 0, `${w.mutations} exports no non-empty MUTATIONS array`);
|
|
const keys = mod.MUTATIONS.map((m) => m.key);
|
|
assert.equal(new Set(keys).size, keys.length, `${w.mutations}: a key is used twice: ${keys.join(' ')}`);
|
|
for (const k of expectKeys[w.rel]) assert.ok(keys.includes(k), `${w.mutations} has no entry ${k}`);
|
|
for (const m of mod.MUTATIONS) {
|
|
assert.ok(ids.includes(m.id), `${w.mutations}: ${m.key} names id ${m.id}, which is not on the IDS= line of ${w.rel}`);
|
|
assert.ok(Array.isArray(m.spans) && m.spans.length > 0, `${w.mutations}: ${m.key} has no spans`);
|
|
for (const s of m.spans) assert.ok(typeof s.file === 'string' && s.file !== '' && !s.file.startsWith('/'), `${w.mutations}: ${m.key} has a span file that is not a relative path`);
|
|
}
|
|
}
|
|
});
|