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>
159 lines
6.9 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|