// 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', 'E22c', 'E22e', 'M5', 'S3', 'E20', 'M9', 'M2', 'K3', 'M1', 'M1r', 'E23a', 'E23b', 'E23d', 'E23f', 'M8', 'M10', 'DJ5', 'M13', 'K2', 'DJ1', 'DJ2', 'DJ4', '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 39 ids', () => { const ids = quotedList(wrapperText(WRAP_B), 'IDS', WRAP_B.rel); assert.equal(IDS_B.length, 39, 'control: the expected list is not 39 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`); } } });