The #2018 regression test used raw fs.rmSync() in afterEach, tripping the local/no-raw-rmsync-in-tests eslint rule (Windows-EBUSY retry budget). CI's lint-tests job (no eslint cache) caught it on next; local --cache runs had false-greened it. Switch to helpers.cleanup(). Also drop two unused destructured imports (evaluateCommandExitZero, interpolate) in the gate-predicate-evaluator test that emitted no-unused-vars warnings. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Unit tests for the gate-predicate evaluator (pure core) — issue #2008.
|
|
*
|
|
* The evaluator is a pure, deps-injected function:
|
|
* evaluatePredicate(predicate, context, deps) -> { block, message, details? }
|
|
* Malformed predicates / unknown kinds THROW (the CLI wrapper maps a throw to a
|
|
* check-command failure so the workflow's `onError` step-1 contract applies).
|
|
*
|
|
* Built-in kind: `command-exit-zero` — runs a declared command via an injected
|
|
* bounded-shell seam; exit 0 => pass, non-zero => block, timeout => block.
|
|
*
|
|
* Per RULESET.TESTS.boundary-coverage: exit code boundary (0/1/2), timedOut
|
|
* true/false, message-trim boundary (MAX / MAX+1), timeout pass-through.
|
|
* Per RULESET.TESTS.property-based: interpolation bijection property (fast-check).
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fc = require('fast-check');
|
|
|
|
const {
|
|
evaluatePredicate,
|
|
COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS,
|
|
COMMAND_MAX_OUTPUT_CHARS,
|
|
COMMAND_MAX_LENGTH,
|
|
EVALUATOR_KINDS,
|
|
} = require('../gsd-core/bin/lib/gate-predicate-evaluator.cjs');
|
|
|
|
// ─── Fake bounded-shell seam ──────────────────────────────────────────────────
|
|
|
|
/** Build a fake runBoundedShell that records the invocation and returns a preset result. */
|
|
function fakeShell(preset) {
|
|
const calls = [];
|
|
const run = (opts) => {
|
|
calls.push(opts);
|
|
return {
|
|
exitCode: preset.exitCode ?? 0,
|
|
stdout: preset.stdout ?? '',
|
|
stderr: preset.stderr ?? '',
|
|
signal: preset.signal ?? null,
|
|
timedOut: preset.timedOut ?? false,
|
|
};
|
|
};
|
|
return { run, calls };
|
|
}
|
|
|
|
const baseCtx = { cwd: '/proj', phaseNumber: '03', phaseDir: '/proj/.planning/phases/03-x', phaseReqIds: 'R-1' };
|
|
|
|
// ─── command-exit-zero: exit-code boundary (0 / 1 / 2) ─────────────────────────
|
|
|
|
describe('evaluatePredicate — command-exit-zero exit mapping', () => {
|
|
test('exit 0 => block:false (gate passes)', () => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'true' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.block, false);
|
|
assert.match(res.message, /exit/i);
|
|
});
|
|
|
|
test('exit 1 => block:true', () => {
|
|
const shell = fakeShell({ exitCode: 1, stderr: 'boom' });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'false' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.block, true);
|
|
assert.match(res.message, /1/);
|
|
assert.match(res.message, /boom/);
|
|
});
|
|
|
|
test('exit 2 => block:true (boundary above 1)', () => {
|
|
const shell = fakeShell({ exitCode: 2 });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'bad' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.block, true);
|
|
});
|
|
|
|
test('exit 127 (ENOENT) => block:true, surfaces not-found', () => {
|
|
const shell = fakeShell({ exitCode: 127, stderr: 'sh: not found' });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'nope' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.block, true);
|
|
assert.match(res.message, /not found|127/);
|
|
});
|
|
|
|
test('non-zero exit with empty stderr still yields a block message', () => {
|
|
const shell = fakeShell({ exitCode: 3, stderr: '', stdout: '' });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'x' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.block, true);
|
|
assert.match(res.message, /3/);
|
|
});
|
|
});
|
|
|
|
// ─── timeout ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('evaluatePredicate — command-exit-zero timeout', () => {
|
|
test('timedOut => block:true with timed-out message', () => {
|
|
const shell = fakeShell({ timedOut: true, exitCode: null, signal: 'SIGTERM' });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'sleep 100', timeout: 5 },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.block, true);
|
|
assert.match(res.message, /timed out|timeout/i);
|
|
});
|
|
|
|
test('default timeout applied when predicate.timeout absent', () => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'true' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(shell.calls[0].timeoutMs, COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS);
|
|
});
|
|
|
|
test('custom timeout (seconds) honored and converted to ms', () => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'true', timeout: 90 },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(shell.calls[0].timeoutMs, 90_000);
|
|
});
|
|
});
|
|
|
|
// ─── interpolation ────────────────────────────────────────────────────────────
|
|
|
|
describe('evaluatePredicate — interpolation', () => {
|
|
test('${PHASE_DIR}, ${PHASE_NUMBER}, ${PHASE_REQ_IDS} are substituted', () => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'check ${PHASE_DIR} ${PHASE_NUMBER} ${PHASE_REQ_IDS}' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(
|
|
shell.calls[0].command,
|
|
'check /proj/.planning/phases/03-x 03 R-1',
|
|
);
|
|
});
|
|
|
|
test('undefined context var => empty string (no leftover placeholder)', () => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'check ${PHASE_REQ_IDS}' },
|
|
{ cwd: '/proj' },
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(shell.calls[0].command, 'check ');
|
|
});
|
|
|
|
test('foreign ${HOME} placeholder left untouched (shell interprets)', () => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'echo ${HOME}' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(shell.calls[0].command, 'echo ${HOME}');
|
|
});
|
|
});
|
|
|
|
// ─── output trimming ──────────────────────────────────────────────────────────
|
|
|
|
describe('evaluatePredicate — message trimming', () => {
|
|
test('stderr longer than COMMAND_MAX_OUTPUT_CHARS is trimmed', () => {
|
|
const long = 'E'.repeat(COMMAND_MAX_OUTPUT_CHARS + 50);
|
|
const shell = fakeShell({ exitCode: 1, stderr: long });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'x' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.block, true);
|
|
assert.equal(res.message.length, COMMAND_MAX_OUTPUT_CHARS);
|
|
});
|
|
|
|
test('stderr exactly at COMMAND_MAX_OUTPUT_CHARS is not trimmed (boundary)', () => {
|
|
const exact = 'E'.repeat(COMMAND_MAX_OUTPUT_CHARS);
|
|
const shell = fakeShell({ exitCode: 1, stderr: exact });
|
|
const res = evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'x' },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(res.message.length, COMMAND_MAX_OUTPUT_CHARS);
|
|
});
|
|
});
|
|
|
|
// ─── malformed predicate / fail-closed ────────────────────────────────────────
|
|
|
|
describe('evaluatePredicate — malformed predicate throws (maps to check-cmd failure)', () => {
|
|
test('missing command throws', () => {
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'command-exit-zero' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/command/i,
|
|
);
|
|
});
|
|
|
|
test('non-string command throws', () => {
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'command-exit-zero', command: 42 }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/command/i,
|
|
);
|
|
});
|
|
|
|
test('empty-string command throws', () => {
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'command-exit-zero', command: ' ' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/command/i,
|
|
);
|
|
});
|
|
|
|
test('oversized command throws (ARGV-overflow guard)', () => {
|
|
const huge = 'a'.repeat(COMMAND_MAX_LENGTH + 1);
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'command-exit-zero', command: huge }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/max length/i,
|
|
);
|
|
});
|
|
|
|
test('non-positive timeout throws', () => {
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'command-exit-zero', command: 'x', timeout: 0 }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/timeout/i,
|
|
);
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'command-exit-zero', command: 'x', timeout: -5 }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/timeout/i,
|
|
);
|
|
});
|
|
|
|
test('non-finite timeout throws', () => {
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'command-exit-zero', command: 'x', timeout: 'forever' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/timeout/i,
|
|
);
|
|
});
|
|
|
|
test('unknown kind throws', () => {
|
|
assert.throws(
|
|
() => evaluatePredicate({ kind: 'no-such-kind', command: 'x' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/kind|unknown|no-such-kind/i,
|
|
);
|
|
});
|
|
|
|
test('predicate not an object throws', () => {
|
|
assert.throws(
|
|
() => evaluatePredicate('nope', baseCtx, { runBoundedShell: fakeShell({}).run }),
|
|
/predicate/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── contract surface ─────────────────────────────────────────────────────────
|
|
|
|
describe('evaluatePredicate — exported contract surface', () => {
|
|
test('EVALUATOR_KINDS advertises command-exit-zero', () => {
|
|
assert.ok(Array.isArray(EVALUATOR_KINDS));
|
|
assert.ok(EVALUATOR_KINDS.includes('command-exit-zero'));
|
|
});
|
|
|
|
test('default timeout is 30s', () => {
|
|
assert.equal(COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS, 30_000);
|
|
});
|
|
|
|
test('cwd is passed through to the shell seam', () => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: 'true' },
|
|
{ cwd: '/custom/proj' },
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.equal(shell.calls[0].cwd, '/custom/proj');
|
|
});
|
|
});
|
|
|
|
// ─── property-based: interpolation bijection on non-placeholder strings ───────
|
|
|
|
describe('evaluatePredicate — interpolation property (fast-check)', () => {
|
|
test('strings without the 3 placeholders pass through unchanged', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string({ maxLength: 40 }).filter((s) => s.trim().length > 0 && !/\$\{(PHASE_NUMBER|PHASE_DIR|PHASE_REQ_IDS)\}/.test(s)),
|
|
(cmd) => {
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: cmd },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
// sh -c receives exactly the input; only the 3 known placeholders would have been rewritten.
|
|
assert.equal(shell.calls[0].command, cmd);
|
|
}),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
test('every placeholder is fully replaced (no leftover ${PHASE_*})', () => {
|
|
fc.assert(
|
|
fc.property(fc.string({ maxLength: 20 }), (noise) => {
|
|
const cmd = `${noise} ${noise}`;
|
|
const shell = fakeShell({ exitCode: 0 });
|
|
evaluatePredicate(
|
|
{ kind: 'command-exit-zero', command: `\${PHASE_DIR}${cmd}\${PHASE_NUMBER}` },
|
|
baseCtx,
|
|
{ runBoundedShell: shell.run },
|
|
);
|
|
assert.doesNotMatch(shell.calls[0].command, /\$\{PHASE_(DIR|NUMBER|REQ_IDS)\}/);
|
|
}),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
});
|