R8 of cleanup R-a (plan .claude/projects/2026-11-07-cleanup-r-a, appendix A). The live tests of the round files move into the test named after their source; each moved test was seen red under a mutation of its source. New: tests/lib/lib-imports.test.mjs, tests/lib/timing.test.mjs (from timing-under-load). The [#1] --gates rule and shim-line pins move into tests/lib/autonomy-gate.test.mjs. Removed: the round test files (retterunde, retterunde-prose-pins, trial-mechanical-fixes, trial2-fixes, trial2-minor, trial2-minor-prose-pins, fix-round-4-5a-prose, fix-round-4-5b2-b-prose, timing-under-load), the 8 R8 e2e scripts, and seven round test files on the R-a removal list that pinned them (fix-round-4-5b-prose, fix-round-4-5b-part-2/3/4-scripts, fix-round-4-5b-part-4-prose, fix-round-4-5b2-b/c-scripts). The diff-mode e2e pin (test 24) and the red-script round-wrapper cases go too. tests/synthetic/plan-run-C.md becomes tests/fixtures/plan-run-c.md, regenerated from buildPlan({ version: '1.9' }). The red judge, reporter and judge test headers now name the shared red script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
// tests/lib/autonomy-gate.test.mjs
|
|
// Cover the autonomy-gate state machine (lib/util/autonomy-gate.mjs):
|
|
// every legal transition + every invalid-transition error + idempotent
|
|
// re-entry to `completed` + CLI-shim JSON-on-stdout contract.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'autonomy-gate.mjs');
|
|
|
|
function runShim(args) {
|
|
try {
|
|
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
return { code: 0, out };
|
|
} catch (e) {
|
|
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
|
}
|
|
}
|
|
|
|
test('idle + start + gates=true → gates_on', () => {
|
|
const r = transition(STATES.IDLE, EVENTS.START, { gates: true });
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.GATES_ON);
|
|
});
|
|
|
|
test('idle + start + gates=false → auto_running', () => {
|
|
const r = transition(STATES.IDLE, EVENTS.START, { gates: false });
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
|
});
|
|
|
|
test('idle + start + gates omitted defaults to auto_running', () => {
|
|
const r = transition(STATES.IDLE, EVENTS.START);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
|
});
|
|
|
|
test('gates_on + phase_boundary → paused_for_gate', () => {
|
|
const r = transition(STATES.GATES_ON, EVENTS.PHASE_BOUNDARY);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.PAUSED_FOR_GATE);
|
|
});
|
|
|
|
test('gates_on + finish → completed', () => {
|
|
const r = transition(STATES.GATES_ON, EVENTS.FINISH);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.COMPLETED);
|
|
});
|
|
|
|
test('auto_running + phase_boundary → auto_running (no pause)', () => {
|
|
const r = transition(STATES.AUTO_RUNNING, EVENTS.PHASE_BOUNDARY);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
|
});
|
|
|
|
test('auto_running + finish → completed', () => {
|
|
const r = transition(STATES.AUTO_RUNNING, EVENTS.FINISH);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.COMPLETED);
|
|
});
|
|
|
|
test('paused_for_gate + resume → gates_on', () => {
|
|
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.RESUME);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.GATES_ON);
|
|
});
|
|
|
|
test('paused_for_gate + finish → completed', () => {
|
|
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.FINISH);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.next_state, STATES.COMPLETED);
|
|
});
|
|
|
|
test('completed + any event → completed (idempotent re-entry)', () => {
|
|
for (const ev of Object.values(EVENTS)) {
|
|
const r = transition(STATES.COMPLETED, ev);
|
|
assert.equal(r.ok, true, `event ${ev} should be tolerated from completed`);
|
|
assert.equal(r.next_state, STATES.COMPLETED, `event ${ev} broke idempotency`);
|
|
}
|
|
});
|
|
|
|
test('idle + non-start event → invalid transition error', () => {
|
|
const r = transition(STATES.IDLE, EVENTS.PHASE_BOUNDARY);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.error, /invalid transition.*idle/);
|
|
});
|
|
|
|
test('gates_on + resume → invalid (resume is only valid from paused_for_gate)', () => {
|
|
const r = transition(STATES.GATES_ON, EVENTS.RESUME);
|
|
assert.equal(r.ok, false);
|
|
});
|
|
|
|
test('auto_running + resume → invalid (auto-mode never pauses)', () => {
|
|
const r = transition(STATES.AUTO_RUNNING, EVENTS.RESUME);
|
|
assert.equal(r.ok, false);
|
|
});
|
|
|
|
test('unknown state rejected with descriptive error', () => {
|
|
const r = transition('zombie', EVENTS.START);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.error, /unknown state/);
|
|
});
|
|
|
|
test('unknown event rejected with descriptive error', () => {
|
|
const r = transition(STATES.IDLE, 'snooze');
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.error, /unknown event/);
|
|
});
|
|
|
|
test('isTerminal — only completed is terminal', () => {
|
|
assert.equal(isTerminal(STATES.COMPLETED), true);
|
|
for (const s of [STATES.IDLE, STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) {
|
|
assert.equal(isTerminal(s), false, `${s} should not be terminal`);
|
|
}
|
|
});
|
|
|
|
test('CLI shim returns valid JSON on success (exit 0)', () => {
|
|
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', 'true']);
|
|
assert.equal(r.code, 0);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.ok, true);
|
|
assert.equal(parsed.next_state, 'gates_on');
|
|
});
|
|
|
|
test('CLI shim returns JSON error on invalid transition (exit 1)', () => {
|
|
const r = runShim(['--state', 'idle', '--event', 'phase_boundary']);
|
|
assert.equal(r.code, 1);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.ok, false);
|
|
assert.match(parsed.error, /invalid transition/);
|
|
});
|
|
|
|
test('CLI shim missing required args returns usage error (exit 1)', () => {
|
|
const r = runShim(['--state', 'idle']);
|
|
assert.equal(r.code, 1);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.ok, false);
|
|
assert.match(parsed.error, /usage:/);
|
|
});
|
|
|
|
// ---- --gates value rule (fix round 4.5a, #1) ---------------------------------
|
|
// The shim is tested only through its CLI; the exit code is asserted before
|
|
// any JSON.parse, so a crash or a usage error never reads as a wrong value.
|
|
|
|
function shimState(args) {
|
|
const r = runShim(args);
|
|
assert.equal(r.code, 0, `expected exit 0 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.ok, true);
|
|
return parsed.next_state;
|
|
}
|
|
|
|
test('[#1] bare --gates as the last argument → gates_on', () => {
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates']), 'gates_on');
|
|
});
|
|
|
|
test('[#1] bare --gates before other flags swallows none of them → exit 0, gates_on', () => {
|
|
assert.equal(shimState(['--gates', '--state', 'idle', '--event', 'start']), 'gates_on');
|
|
});
|
|
|
|
test('[#1] --gates closed → gates_on', () => {
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'closed']), 'gates_on');
|
|
});
|
|
|
|
for (const bad of ['TRUE', 'yes', 'adaptive']) {
|
|
test(`[#1] --gates ${bad} → usage error (exit 1)`, () => {
|
|
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', bad]);
|
|
assert.equal(r.code, 1, `expected exit 1 for --gates ${bad}, got ${r.code}: ${r.out}`);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.ok, false);
|
|
assert.match(parsed.error, /usage/);
|
|
});
|
|
}
|
|
|
|
test('--gates true → gates_on', () => {
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'true']), 'gates_on');
|
|
});
|
|
|
|
test('--gates false → auto_running', () => {
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'false']), 'auto_running');
|
|
});
|
|
|
|
test('--gates open → auto_running', () => {
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'open']), 'auto_running');
|
|
});
|
|
|
|
// The `=` form reaches the shim whole as one token; it is read as `--gates <value>`.
|
|
test('[#1] sibling: --gates=<value> is read as --gates <value>; an unknown or empty value is the usage error', () => {
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=true']), 'gates_on');
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=closed']), 'gates_on');
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=false']), 'auto_running');
|
|
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=open']), 'auto_running');
|
|
for (const bad of ['--gates=maybe', '--gates=']) {
|
|
const r = runShim(['--state', 'idle', '--event', 'start', bad]);
|
|
assert.equal(r.code, 1, `expected exit 1 for ${bad}, got ${r.code}: ${r.out}`);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.ok, false);
|
|
assert.match(parsed.error, /usage: --gates takes true, false, closed or open/);
|
|
}
|
|
});
|
|
|
|
// ---- the four commands' --gates rule and shim line ----------------------------
|
|
// Each of the four commands holds one rule: the next token is the flag's value
|
|
// only when it is true, false, closed or open; the shim maps it, run once; a
|
|
// broken shim falls back to the same mapping in prose and the run goes on.
|
|
|
|
const ROOT = join(HERE, '..', '..');
|
|
const GATES_COMMANDS = ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute'];
|
|
|
|
const GATES_RULE = [
|
|
'`--gates` takes the next token as its value only when it is `true`, `false`, `closed` or `open` (strip both); any other next token is not its value (strip only the flag).',
|
|
'Run the shim once to map the value, bare when there is no value:',
|
|
'`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it false, and nothing else reads the state.',
|
|
'On exit 1 or no JSON (a broken shim; the four values cannot cause it), print the error in one line and map in prose instead: bare, `true`, `closed` → true; `false`, `open` → false. The run goes on either way.',
|
|
];
|
|
|
|
const commandText = (cmd) => readFileSync(join(ROOT, 'commands', `${cmd}.md`), 'utf8');
|
|
|
|
/** Whitespace (line breaks too) collapsed to one space. */
|
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
|
|
/** The command's one shim line, `[value]` filled in with `value` (left out when ''). */
|
|
function gatesShim(cmd, value) {
|
|
const hits = commandText(cmd).split('\n')
|
|
.filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l) && l.includes('--event start'));
|
|
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs --event start line; found ${hits.length}`);
|
|
const l = hits[0].trim();
|
|
assert.match(l, / --state idle --event start --gates \[value\]$/, `${cmd}: the shim line must end "--gates [value]"`);
|
|
return l.replace(' [value]', value ? ` ${value}` : '');
|
|
}
|
|
|
|
const shimCache = new Map();
|
|
|
|
/** Run a shim line from the prose with the plugin root filled in; its `next_state`. Each distinct line runs once. */
|
|
function shimNext(cmdLine) {
|
|
if (!shimCache.has(cmdLine)) {
|
|
const r = spawnSync('bash', ['-c', cmdLine.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd: ROOT, encoding: 'utf8' });
|
|
assert.equal(r.status, 0, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
|
|
shimCache.set(cmdLine, JSON.parse(r.stdout).next_state);
|
|
}
|
|
return shimCache.get(cmdLine);
|
|
}
|
|
|
|
for (const cmd of GATES_COMMANDS) {
|
|
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a shim line that maps closed to gates_on`, () => {
|
|
const text = commandText(cmd);
|
|
const f = flat(text);
|
|
for (const s of GATES_RULE) assert.ok(f.includes(s), `${cmd}: missing the shared --gates sentence: ${s}`);
|
|
assert.doesNotMatch(text, /--state X --event Y/, `${cmd}: the placeholder shim line is still there`);
|
|
assert.doesNotMatch(f, /consumed by the autonomy-gate/, `${cmd}: still says the flag is consumed by the state machine`);
|
|
assert.equal(shimNext(gatesShim(cmd, 'closed')), 'gates_on');
|
|
});
|
|
}
|
|
|
|
// `/trekplan --gates --project d` and `/trekexecute --gates plan.md` are a bare
|
|
// --gates (the next token is not a value), so the shim runs bare.
|
|
test('every command\'s shim line maps a bare --gates to gates_on and open to auto_running', () => {
|
|
for (const cmd of GATES_COMMANDS) {
|
|
assert.equal(shimNext(gatesShim(cmd, '')), 'gates_on', `${cmd}: bare --gates`);
|
|
assert.equal(shimNext(gatesShim(cmd, 'open')), 'auto_running', `${cmd}: --gates open`);
|
|
}
|
|
});
|