// tests/lib/fix-round-4-5b-part-2-scripts.test.mjs // The form of fix round 4-5b part 2's three shell scripts (plan // .claude/projects/2026-10-27-fix-round-4-5b-part-2, Step 5h, decision 88): // the red wrapper, the regression script and the end-to-end run. Untagged on // purpose and listed in no red wrapper's FILES: these checks are not a part-2 // fix, and they fail at no start point. // - each script is ASCII only, passes /bin/bash -n and uses no bash 4 form; // - the red wrapper hands the shared red script exactly one --other-ids, // whose words are the 4-5a IDS= words followed by part 1's, and --ids // TOK HDG CKI DLC KI (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 the 4-5a or the part-1 wrapper lacking its IDS= line, the red // wrapper exits 2 with a setup failure and never runs the shared script. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 RED = 'tests/fix-round-4-5b-part-2-red.sh'; const REGRESSION = 'tests/fix-round-4-5b-part-2-regression.sh'; const E2E = 'tests/e2e-fix-round-4-5b-part-2.sh'; const WRAP_A = 'tests/fix-round-4-5a-red.sh'; const WRAP_B = 'tests/fix-round-4-5b-red.sh'; const PART2_IDS = ['TOK', 'HDG', 'CKI', 'DLC', 'KI']; /** 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 words of a wrapper's one IDS='...' line. */ function idsOf(rel) { const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS=')); assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`); const m = lines[0].match(/^IDS='([^']*)'$/); assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`); return m[1].split(/\s+/).filter(Boolean); } test('part-2 scripts: each 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/, /\|&/, /^\s*[A-Za-z_]+=\(/m, /\$\{[A-Za-z_]+,,\}/, /\$\{[A-Za-z_]+\^\^\}/]) { assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`); } } }); /** 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-2 wrapper, both older 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, WRAP_A, WRAP_B]) writeFileSync(join(dir, rel), bytes(rel)); writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 }); return dir; } /** The part-2 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 }; } test('part-2 scripts: the red wrapper passes one --other-ids, the 4-5a ids then part 1\'s, and its own five ids', () => { 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(), 'part2-red-wrap-')); try { const r = runWrapper(stubRoot(base)); assert.equal(r.status, 0, `${RED} did not run the shared script:\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, [...idsOf(WRAP_A), ...idsOf(WRAP_B)], `${RED}: --other-ids is not the IDS= words of ${WRAP_A} followed by those of ${WRAP_B}\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), PART2_IDS, `${RED}: --ids is not ${PART2_IDS.join(' ')}\n${r.out}`); } finally { rmSync(base, { recursive: true, force: true }); } }); test('part-2 scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => { for (const missing of [WRAP_A, WRAP_B]) { const base = mkdtempSync(join(tmpdir(), 'part2-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.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`); } finally { rmSync(base, { recursive: true, force: true }); } } });