257 lines
13 KiB
JavaScript
257 lines
13 KiB
JavaScript
// tests/lib/fix-round-4-5b2-b-scripts.test.mjs
|
|
// The form of fix round 4.5b2 part B's shell scripts and mutation module (plan
|
|
// .claude/projects/2026-10-31-review-gate-4-5b2, Step 16): the red wrapper,
|
|
// the mutations, the regression script and the round's end-to-end run.
|
|
// Untagged on purpose and listed in no red wrapper's FILES: these checks are
|
|
// not a part-B fix.
|
|
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
|
|
// bash 4 form; the mutation module exists and is ASCII only;
|
|
// - the red wrapper hands the shared red script the base 96fba75, exactly
|
|
// one --other-ids, whose words are the IDS= words of the six earlier
|
|
// wrappers in order (4-5a, 4-5b, part 2, part 3, part 4, 4-5b2-a), the
|
|
// round's twenty ids, the part-B mutation module and exactly the twelve
|
|
// tagged test files of this round (run from a temp root next to a stub
|
|
// red-round.sh that prints its arguments, the method of
|
|
// tests/lib/red-script.test.mjs);
|
|
// - with any earlier wrapper lacking its IDS= line, the red wrapper exits 2
|
|
// with a setup failure and never runs the shared script;
|
|
// - red-mutate.mjs list prints exactly the one mutation `FXS-fixture FXS`
|
|
// (the FXS case reads a file under tests/, which the red script copies
|
|
// into the base clone, so only a mutation makes it red there);
|
|
// - every test file under tests/ that holds one of the round's tags is one
|
|
// of the twelve, and none of the twelve is in an earlier wrapper's FILES;
|
|
// - the regression script runs the 4-5b red proof and the 4.5a, part-4 and
|
|
// part-A end-to-end scripts in that order, prints each exit and exits
|
|
// with the first non-zero status (run from a temp root with stubs).
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { childEnv } from '../helpers/child-env.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
|
|
const RED = 'tests/fix-round-4-5b2-b-red.sh';
|
|
const REGRESSION = 'tests/fix-round-4-5b2-b-regression.sh';
|
|
const E2E = 'tests/e2e-fix-round-4-5b2-b.sh';
|
|
const MUTATIONS = 'tests/fix-round-4-5b2-b-mutations.mjs';
|
|
const SELF = 'tests/lib/fix-round-4-5b2-b-scripts.test.mjs';
|
|
const BASE = '96fba75996be4fa295d80d7a42c8e3554f7aaae4';
|
|
const OLDER = [
|
|
'tests/fix-round-4-5a-red.sh',
|
|
'tests/fix-round-4-5b-red.sh',
|
|
'tests/fix-round-4-5b-part-2-red.sh',
|
|
'tests/fix-round-4-5b-part-3-red.sh',
|
|
'tests/fix-round-4-5b-part-4-red.sh',
|
|
'tests/fix-round-4-5b2-a-red.sh',
|
|
];
|
|
const PARTB_IDS = ['CAT', 'V12', 'RAP', 'ANS', 'RLS', 'PRX', 'RND', 'CAP', 'CRY', 'FXL', 'POL', 'ENG', 'CMD', 'DPR', 'MEV', 'PRB',
|
|
'RMP', 'PXB', 'FN1', 'FXS'];
|
|
const PARTB_FILES = [
|
|
'tests/lib/review-answers.test.mjs',
|
|
'tests/lib/review-validator-1-2.test.mjs',
|
|
'tests/lib/pre-existing.test.mjs',
|
|
'tests/lib/review-rounds.test.mjs',
|
|
'tests/lib/review-policy.test.mjs',
|
|
'tests/lib/engine-findings-compare.test.mjs',
|
|
'tests/lib/claude-md-scope.test.mjs',
|
|
'tests/hooks/merge-evidence.test.mjs',
|
|
'tests/lib/fix-round-4-5b2-b-prose.test.mjs',
|
|
'tests/lib/reviewed-commit-parents.test.mjs',
|
|
'tests/lib/pre-existing-bound.test.mjs',
|
|
'tests/lib/review-finalized.test.mjs',
|
|
];
|
|
const REGRESSION_RUNS = [
|
|
'tests/fix-round-4-5b-red.sh',
|
|
'tests/e2e-fix-round-4-5a.sh',
|
|
'tests/e2e-fix-round-4-5b-part-4.sh',
|
|
'tests/e2e-fix-round-4-5b2-a.sh',
|
|
];
|
|
/** The shared script's options that take a value; every other argument after them is a test file. */
|
|
const VALUE_OPTIONS = ['--base', '--ids', '--other-ids', '--mutations'];
|
|
|
|
/** A repo file's bytes; the file must exist. */
|
|
function bytes(rel) {
|
|
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
|
return readFileSync(join(ROOT, rel));
|
|
}
|
|
|
|
/** The single-quoted words of a wrapper's one <name>='...' line. */
|
|
function wordsOf(rel, name) {
|
|
const lines = bytes(rel).toString('utf8').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);
|
|
}
|
|
|
|
test('part-B scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
|
|
for (const rel of [RED, REGRESSION, E2E]) {
|
|
const buf = bytes(rel);
|
|
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
|
|
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
|
|
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
|
|
const text = buf.toString('utf8');
|
|
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/]) {
|
|
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
|
|
}
|
|
}
|
|
const mod = bytes(MUTATIONS);
|
|
assert.equal(mod.findIndex((b) => b > 127), -1, `${MUTATIONS} is not ASCII only`);
|
|
});
|
|
|
|
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
|
|
const STUB = `#!/bin/bash
|
|
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
|
|
exit 0
|
|
`;
|
|
|
|
/** A temp root with the part-B wrapper, the six earlier wrappers and the stub as tests/helpers/red-round.sh. */
|
|
function stubRoot(base) {
|
|
const dir = join(base, 'root');
|
|
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
|
|
for (const rel of [RED, ...OLDER]) writeFileSync(join(dir, rel), bytes(rel));
|
|
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
|
|
return dir;
|
|
}
|
|
|
|
/** The part-B wrapper run from a temp root: status, output and the arguments the stub saw. */
|
|
function runWrapper(dir) {
|
|
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
|
|
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 arguments that are neither an option nor an option's value: the test files. */
|
|
function filesOf(argv) {
|
|
const files = [];
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
if (VALUE_OPTIONS.includes(argv[i])) { i += 1; continue; }
|
|
if (argv[i].startsWith('--')) continue;
|
|
files.push(argv[i]);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
test('part-B scripts: the red wrapper passes the base, one --other-ids with the six earlier rounds\' ids in order, its twenty ids, its mutations and its twelve tagged files', () => {
|
|
const text = bytes(RED).toString('utf8');
|
|
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
|
|
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
|
|
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
|
|
|
|
const base = mkdtempSync(join(tmpdir(), 'partB-red-wrap-'));
|
|
try {
|
|
const dir = stubRoot(base);
|
|
const r = runWrapper(dir);
|
|
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
|
|
const baseAt = r.argv.indexOf('--base');
|
|
assert.ok(baseAt >= 0, `${RED} passed no --base:\n${r.out}`);
|
|
assert.equal(r.argv[baseAt + 1], BASE, `${RED}: --base is not ${BASE}\n${r.out}`);
|
|
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
|
|
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
|
|
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
|
|
assert.deepEqual(other, OLDER.flatMap((rel) => wordsOf(rel, 'IDS')),
|
|
`${RED}: --other-ids is not the IDS= words of ${OLDER.join(', then ')}\n${r.out}`);
|
|
for (const id of PARTB_IDS) {
|
|
assert.ok(!other.includes(id), `${RED}: the round's tag ${id} is on --other-ids, so it would never be judged\n${r.out}`);
|
|
}
|
|
const idsAt = r.argv.indexOf('--ids');
|
|
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
|
|
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PARTB_IDS, `${RED}: --ids is not ${PARTB_IDS.join(' ')}\n${r.out}`);
|
|
const mutAt = r.argv.indexOf('--mutations');
|
|
assert.ok(mutAt >= 0, `${RED} passed no --mutations:\n${r.out}`);
|
|
assert.ok(r.argv[mutAt + 1].startsWith('/'), `${RED}: --mutations is not an absolute path: ${r.argv[mutAt + 1]}`);
|
|
assert.ok(r.argv[mutAt + 1].endsWith(`/${MUTATIONS}`), `${RED}: --mutations is not ${MUTATIONS}: ${r.argv[mutAt + 1]}`);
|
|
assert.deepEqual([...filesOf(r.argv)].sort(), [...PARTB_FILES].sort(),
|
|
`${RED}: the test files are not exactly the twelve tagged files of this round\n${r.out}`);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('part-B scripts: the red wrapper exits 2 with a setup failure when an earlier wrapper has no IDS= line', () => {
|
|
for (const missing of OLDER) {
|
|
const base = mkdtempSync(join(tmpdir(), 'partB-red-noids-'));
|
|
try {
|
|
const dir = stubRoot(base);
|
|
const text = bytes(missing).toString('utf8');
|
|
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
|
|
const r = runWrapper(dir);
|
|
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
|
|
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
|
|
assert.ok(r.stderr.includes(missing), `${RED}: the setup failure line does not name ${missing}\n${r.out}`);
|
|
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
test('part-B scripts: red-mutate.mjs list prints exactly the mutation `FXS-fixture FXS`', () => {
|
|
assert.ok(existsSync(join(ROOT, MUTATIONS)), `missing file ${MUTATIONS}`);
|
|
const r = spawnSync(process.execPath, [join(ROOT, 'tests', 'helpers', 'red-mutate.mjs'), 'list', MUTATIONS],
|
|
{ cwd: ROOT, encoding: 'utf8', timeout: 30000, env: childEnv() });
|
|
assert.equal(r.status, 0, `red-mutate.mjs list ${MUTATIONS} exited ${r.status}:\n${r.stdout}${r.stderr}`);
|
|
const lines = (r.stdout || '').split('\n').filter((l) => l !== '');
|
|
assert.deepEqual(lines, ['FXS-fixture FXS'],
|
|
`red-mutate.mjs list ${MUTATIONS} does not print exactly the mutation FXS-fixture FXS:\n${r.stdout}${r.stderr}`);
|
|
});
|
|
|
|
/** Every .mjs file under tests/, as a repo-relative path. */
|
|
function testModules(dir = join(ROOT, 'tests')) {
|
|
const out = [];
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...testModules(p));
|
|
else if (e.name.endsWith('.mjs')) out.push(relative(ROOT, p).split('\\').join('/'));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
test('part-B scripts: every file holding a part-B tag is one of the twelve, and none of the twelve is in an earlier wrapper\'s FILES', () => {
|
|
const tag = new RegExp(`\\[(?:${PARTB_IDS.join('|')})\\]`);
|
|
const tagged = testModules().filter((rel) => rel !== SELF && tag.test(readFileSync(join(ROOT, rel), 'utf8')));
|
|
assert.deepEqual([...tagged].sort(), [...PARTB_FILES].sort(),
|
|
`the files under tests/ that hold a part-B tag are not exactly the twelve of ${RED}`);
|
|
for (const rel of OLDER) {
|
|
const files = wordsOf(rel, 'FILES');
|
|
for (const f of PARTB_FILES) {
|
|
assert.ok(!files.includes(f), `${f} is in the FILES of the earlier wrapper ${rel}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
/** A temp root with the regression script and a stub per script it runs, each exiting with its given status. */
|
|
function regressionRoot(base, codes) {
|
|
const dir = join(base, 'root');
|
|
mkdirSync(join(dir, 'tests'), { recursive: true });
|
|
writeFileSync(join(dir, REGRESSION), bytes(REGRESSION));
|
|
REGRESSION_RUNS.forEach((rel, i) => {
|
|
writeFileSync(join(dir, rel), `#!/bin/bash\necho "RAN:${rel}"\nexit ${codes[i]}\n`, { mode: 0o755 });
|
|
});
|
|
return dir;
|
|
}
|
|
|
|
test('part-B scripts: the regression script runs the 4-5b red proof and three end-to-end scripts in order and exits with the first non-zero status', () => {
|
|
for (const [codes, want] of [[[0, 0, 0, 0], 0], [[0, 3, 0, 5], 3], [[4, 0, 0, 0], 4], [[0, 0, 0, 6], 6]]) {
|
|
const base = mkdtempSync(join(tmpdir(), 'partB-regression-'));
|
|
try {
|
|
const dir = regressionRoot(base, codes);
|
|
const r = spawnSync('/bin/bash', [join(dir, REGRESSION)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
|
|
const out = `${r.stdout}${r.stderr}`;
|
|
assert.equal(r.status, want, `${REGRESSION} with exits ${codes.join(',')} exited ${r.status}, not ${want}\n${out}`);
|
|
const ran = (r.stdout || '').split('\n').filter((l) => l.startsWith('RAN:')).map((l) => l.slice(4));
|
|
assert.deepEqual(ran, REGRESSION_RUNS, `${REGRESSION} did not run all four scripts in order\n${out}`);
|
|
REGRESSION_RUNS.forEach((rel, i) => {
|
|
assert.ok((r.stdout || '').includes(`regression: ${rel} exited ${codes[i]}`),
|
|
`${REGRESSION} printed no "regression: ${rel} exited ${codes[i]}" line\n${out}`);
|
|
});
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|