Files
msd-core/tests/normalize-test-command.test.cjs
Tom Boucher 603593d41d fix(#1857): test gates normalize to one-shot + bounded timeout (no watch-mode hang)
A GSD verification gate resolves a project's test command and runs it. vitest
defaults to WATCH mode in an interactive TTY — exactly where a user runs
`gsd-execute-phase` — so a resolved `npm test`/`pnpm test` backed by vitest never
exited and the orchestrator waited indefinitely. Recovery needed the user to
manually prompt "something blocking?".

Fix — one shared helper + a bounded, surfacing timeout on the test-command gates:
- New pure module src/normalize-test-command.cts + `gsd-tools query
  normalize-test-command` verb: rewrites a resolved command to a best-effort
  one-shot form (direct vitest → `vitest run`; jest `--watch` → `--watchAll=false`;
  a package-manager `test` script whose package.json runner is watch-vitest →
  `CI=true` prefix; handles `--dir`; already-one-shot commands unchanged —
  never double-flagged). Named `normalize-test-command` (not `test-*`) so the
  file does not match node --test's default `test-*` discovery glob.
- The three gates that HUNG or silently-continued route through that ONE helper
  and bound execution with `timeout $(config-get workflow.test_gate_timeout)`
  (new config key, default 600s): the regression gate (extracted to
  execute-phase/steps/regression-gate.md since execute-phase.md is size-frozen —
  it shrank 93528→93132; ABORTS on exit 124), the post-merge gate, and the
  audit-fix gate (previously an UNBOUNDED `eval`). All name watch/dev mode on 124.
- verify-phase's gate was ALREADY bounded (a fixed `timeout 300`, not a hang), so
  it only gains the normalizer (so a watch runner exits fast) + a watch-mode hint
  on 124, staying under its frozen 40960-byte tier cap.

Security hardening (review): the normalizer only rewrites a runner named as a
standalone command TOKEN (so `run-vitest.js`/`make test-vitest`/paths are never
mangled), is length-capped and uses only linear-time split-based scanning (no
super-linear backtracking on an adversarial `workflow.test_command`), and reads
package.json only when it is a regular file (never blocks on a FIFO via `--dir`).

Config key `workflow.test_gate_timeout` (seconds, default 600) registered in the
schema manifest + templates/config.json + docs/CONFIGURATION.md (mirrors
workflow.cross_ai_timeout). New module registered in .gitignore, eslint ignores,
inventory manifest/index. All 16 golden-install-parity fixtures + workflow size
baseline regenerated for the changed shipped files; bin/lib is excluded from the
parity manifest.

Tests: tests/normalize-test-command.test.cjs (normalizer units incl. security
hardening) and tests/test-gate-watch-mode.test.cjs (the three core gates route
through the shared helper + configured timeout + exit-124 watch-mode hint;
verify-phase asserted as normalize-only/already-bounded).
tests/execute-phase-active-flags.test.cjs repointed at the extracted step;
tests/planner-language-regression.test.cjs allowlist comment updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:14:43 -04:00

159 lines
6.9 KiB
JavaScript

'use strict';
/**
* Regression tests for the test-command normalizer (#1857).
*
* A GSD test gate must not hang forever on a watch-mode runner. The normalizer
* rewrites a resolved test command to a best-effort one-shot form; the gate's
* wall-clock timeout is the ultimate guarantee (asserted separately as workflow
* content). These tests cover the two normalization acceptance criteria:
* (a) an already-one-shot command is invoked UNCHANGED (never double-flagged),
* (b) a watch-mode command is normalized to a one-shot form.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const normalizer = require('../gsd-core/bin/lib/normalize-test-command.cjs');
const { normalizeTestCommand, isAlreadyOneShot } = normalizer;
function tmpProject(testScript) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1857-'));
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ scripts: { test: testScript } }),
);
return dir;
}
describe('normalizeTestCommand: direct vitest (watch by default) → one-shot (#1857)', () => {
test('bare vitest gains "run"', () => {
assert.strictEqual(normalizeTestCommand('vitest', '/tmp'), 'vitest run');
});
test('npx vitest with flags gains "run" and keeps flags', () => {
assert.strictEqual(normalizeTestCommand('npx vitest --coverage', '/tmp'), 'npx vitest run --coverage');
});
test('vitest --watch is stripped and made one-shot', () => {
assert.strictEqual(normalizeTestCommand('vitest --watch', '/tmp'), 'vitest run');
});
});
describe('normalizeTestCommand: already one-shot is a no-op (#1857)', () => {
for (const cmd of ['vitest run', 'vitest --run', 'npx vitest run -t foo', 'jest --watchAll=false']) {
test(`"${cmd}" is recognised one-shot and returned unchanged`, () => {
assert.strictEqual(normalizeTestCommand(cmd, '/tmp'), cmd);
assert.ok(isAlreadyOneShot(cmd), `"${cmd}" should be recognised as one-shot`);
});
}
// jest is one-shot by default (no --watch); --runInBand doesn't disable watch,
// so it isn't a one-shot MARKER — but a jest command without a watch flag is
// still returned unchanged via the jest branch.
for (const cmd of ['jest', 'jest --runInBand']) {
test(`"${cmd}" (jest, no watch flag) is returned unchanged`, () => {
assert.strictEqual(normalizeTestCommand(cmd, '/tmp'), cmd);
});
}
});
describe('normalizeTestCommand: direct jest with watch flag → one-shot (#1857)', () => {
test('jest --watch → --watchAll=false', () => {
assert.strictEqual(normalizeTestCommand('jest --watch', '/tmp'), 'jest --watchAll=false');
});
test('jest --watchAll → --watchAll=false', () => {
assert.strictEqual(normalizeTestCommand('jest --watchAll', '/tmp'), 'jest --watchAll=false');
});
});
describe('normalizeTestCommand: package-manager script invocation inspects package.json (#1857)', () => {
let vitestDir; let vitestRunDir; let jestDir;
beforeEach(() => {
vitestDir = tmpProject('vitest'); // watches by default
vitestRunDir = tmpProject('vitest run'); // already one-shot
jestDir = tmpProject('jest'); // one-shot by default
});
afterEach(() => {
for (const d of [vitestDir, vitestRunDir, jestDir]) {
cleanup(d);
}
});
test('npm test whose script is watch-vitest → CI=true prefix', () => {
assert.strictEqual(normalizeTestCommand('npm test', vitestDir), 'CI=true npm test');
});
test('pnpm test whose script is watch-vitest → CI=true prefix', () => {
assert.strictEqual(normalizeTestCommand('pnpm test', vitestDir), 'CI=true pnpm test');
});
test('npm test whose script is already "vitest run" → unchanged', () => {
assert.strictEqual(normalizeTestCommand('npm test', vitestRunDir), 'npm test');
});
test('npm test whose script is jest (one-shot) → unchanged', () => {
assert.strictEqual(normalizeTestCommand('npm test', jestDir), 'npm test');
});
test('pnpm --dir <app> test inspects the target package.json (#1857 comment 5)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1857-root-'));
try {
fs.mkdirSync(path.join(root, 'app'));
fs.writeFileSync(path.join(root, 'app', 'package.json'), JSON.stringify({ scripts: { test: 'vitest' } }));
assert.strictEqual(normalizeTestCommand('pnpm --dir app test', root), 'CI=true pnpm --dir app test');
// An explicit --run the user already passed is respected (no-op); the
// gate timeout backstops a project that still watches despite --run.
assert.strictEqual(normalizeTestCommand('pnpm --dir app test -- --run', root), 'pnpm --dir app test -- --run');
} finally {
cleanup(root);
}
});
});
describe('normalizeTestCommand: non-JS / unknown runners are untouched (#1857)', () => {
for (const cmd of ['cargo test', 'go test ./...', 'make test', 'python -m pytest -q', 'true', '']) {
test(`"${cmd}" is returned unchanged`, () => {
assert.strictEqual(normalizeTestCommand(cmd, '/tmp'), cmd);
});
}
test('npm test with no package.json is left unchanged (cannot classify)', () => {
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1857-empty-'));
try {
assert.strictEqual(normalizeTestCommand('npm test', empty), 'npm test');
} finally {
cleanup(empty);
}
});
});
describe('normalizeTestCommand: security hardening (#1857 review)', () => {
// "vitest"/"jest" as a token that isn't the invoked binary must NOT be mangled.
for (const cmd of ['make test-vitest', 'node ./scripts/run-vitest.js', './bin/vitest-wrapper.sh', 'cat vitest.config.js', 'node jest-runner.js']) {
test(`"${cmd}" is not mangled (word-token, not substring)`, () => {
assert.strictEqual(normalizeTestCommand(cmd, '/tmp'), cmd);
});
}
test('an oversized command is returned unchanged and in linear time (no ReDoS)', () => {
// The blow-up input from the review: a long "npm " run with no `test` token.
const huge = 'npm '.repeat(200000); // ~800 KB
const start = Date.now();
const out = normalizeTestCommand(huge, '/tmp');
const elapsedMs = Date.now() - start;
assert.strictEqual(out, huge, 'oversized input must be returned unchanged');
assert.ok(elapsedMs < 250, `normalization must be fast even on adversarial input (took ${elapsedMs}ms)`);
});
test('a package.json that is not a regular file is ignored (no FIFO hang)', () => {
// resolvePackageDir would point here; a non-regular "package.json" must be skipped.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1857-notfile-'));
try {
fs.mkdirSync(path.join(dir, 'package.json')); // a DIRECTORY named package.json
// Classified as "cannot determine runner" → command returned unchanged.
assert.strictEqual(normalizeTestCommand('npm test', dir), 'npm test');
} finally {
cleanup(dir);
}
});
});