Files
msd-core/tests/faulty-deps.test.cjs
Tom Boucher 30468f16fe test(#4515): migrate installer-runs batch to named timeout constants (#4575)
* test(#4515): migrate installer-runs batch to named timeout constants

Batch 4 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property across
tests/install.test.cjs, tests/install-minimal-hooks.test.cjs,
tests/fragment-single-edit-propagation.install.test.cjs,
tests/install-regressions.test.cjs, tests/install-runtime-artifacts.test.cjs,
tests/npm-integrity-gate.test.cjs, tests/faulty-deps.test.cjs,
tests/release-tarball-smoke.install.test.cjs,
tests/install-write-confinement.test.cjs, tests/plugin-manifest.test.cjs,
and tests/packaging-shipped-scripts-require-only-shipped.test.cjs with a
named constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes
these 11 files from the rule's allowlist.

Adds one new shared constant to tests/helpers/timeouts.cjs for a
fixture-JSON value (in seconds, not ms) mimicking a Claude Code
settings.json hook-entry's own timeout field, shared across two files in
this batch. Every other new constant is file-local, each carrying a
comment explaining why its call site is a distinct operational class from
the existing shared norms even where its digits numerically coincide with
one. No src/bin file touched, no numeric timeout value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: combine gsd-worktree-path-guard's git rev-parse calls to cut subprocess count under CI load

Discovered while verifying PR #4575 (a full test (windows-latest) failure in an unrelated test, tests/kilo-upgrades.test.cjs's worktree-path-guard rejection test). Root cause: this hook's up-to-4 sequential git spawns (git-dir, branch, worktree-toplevel, file-toplevel), invoked inside a native-plugin's own 8000ms-capped subprocess wrapper, left zero margin for node/git startup overhead — the guard is deliberately fail-open on any timeout, so CI-load-induced latency in this chain silently downgrades a security block into an allow. Combines the first three git rev-parse calls (git-dir, branch via --abbrev-ref, worktree-toplevel) into ONE spawn instead of three, using git's documented multi-flag rev-parse output (one line per flag, in order) — verified empirically including the detached-HEAD edge case. No timeout value changed; this reduces subprocess COUNT, not any tolerance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: add changeset fragment for the worktree-path-guard fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:45:44 -04:00

361 lines
16 KiB
JavaScript

'use strict';
/**
* Phase 2 test matrix for issue #3056 (fault-injection adapter) and #3071
* (execGit normalization, folded in). See
* .gsd/phase/test-3056-fault-injection-adapter/50-test-matrix.md.
*
* Section D (row 24 — injection through the process seam is unsupported) has
* no runtime assertion: it is documented in tests/helpers/faulty-deps.cjs's
* module JSDoc and in CONTRIBUTING.md, per the matrix's own note that this
* row is "asserted by review + absence, not a runtime test." Likewise row 23
* (no chmod anywhere in the helper) is asserted by review of
* tests/helpers/faulty-deps.cjs, not by a runtime test.
*/
const { describe, test, mock } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const childProcess = require('node:child_process');
const {
execGit,
execNpm,
execTool,
isSpawnTimeout,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const worktreeSafety = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-safety.cjs'));
const { trySymbolicRef } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'git-base-branch.cjs'));
const { evaluateWorktreeBaseDegrade } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'));
const { defaultPhaseCleanCommitTimesMs } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verification.cjs'));
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
const { makeFaultyGit, withFaultyFs } = require('./helpers/faulty-deps.cjs');
// A bound so small process creation cannot complete inside it, making the
// ETIMEDOUT kill path deterministic on a real (unmocked) git call; used at
// both sentinel sites in this file.
const DETERMINISTIC_TIMEOUT_SENTINEL_MS = 1;
// Bounds a real (unmocked) `git var GIT_EDITOR` call proving env passthrough;
// deliberately not GIT_TIMEOUT_MS (15000ms) since this site's pre-existing
// value differs and this migration never raises a bound without a fresh
// bench citation.
const GIT_VAR_PROBE_TIMEOUT_MS = 5000;
// ─── A. execGit normalization (#3071) ──────────────────────────────────────
describe('A. execGit normalization (#3071)', () => {
let tmpDir;
test('1. execGit result carries timedOut', (t) => {
tmpDir = createTempGitProject();
t.after(() => cleanup(tmpDir));
const result = execGit(['status', '--porcelain'], { cwd: tmpDir });
assert.strictEqual(result.timedOut, false);
assert.strictEqual(typeof result.exitCode, 'number');
});
test('2. every exec* result carries timedOut', () => {
const npmResult = execNpm(['--version']);
const toolResult = execTool(process.execPath, ['--version']);
assert.strictEqual(typeof npmResult.timedOut, 'boolean');
assert.strictEqual(npmResult.timedOut, false);
assert.strictEqual(typeof toolResult.timedOut, 'boolean');
assert.strictEqual(toolResult.timedOut, false);
});
test('3. ENOENT path still sets timedOut:false', () => {
// Reached via a real execTool call to a nonexistent binary — the early
// ENOENT return in _spawnResult (shell-command-projection.cts) must not
// omit the field the rest of the shape now always carries.
const result = execTool('definitely-not-a-real-program-fault-3056', []);
assert.strictEqual(result.exitCode, 127);
assert.strictEqual(result.timedOut, false);
});
test('4. a timed-out git reports timedOut', (t) => {
tmpDir = createTempGitProject();
t.after(() => cleanup(tmpDir));
// timeout:1 is real (no mocking) — process creation alone cannot
// complete inside 1ms, so the kill path is deterministic, matching the
// existing "wall-clock timeout" pattern used for dispatchGsdCommand in
// shell-command-projection-dispatch.test.cjs.
const result = execGit(['status', '--porcelain'], { cwd: tmpDir, timeout: DETERMINISTIC_TIMEOUT_SENTINEL_MS });
assert.strictEqual(result.timedOut, true);
assert.strictEqual(result.error && result.error.code, 'ETIMEDOUT');
});
test('5. an external SIGTERM is not reported as a timeout', (t) => {
// Direct predicate check: a SIGTERM with no accompanying ETIMEDOUT error
// (the shape an externally-delivered kill produces) must not trip
// isSpawnTimeout — proves dropping the SIGTERM conjunct (#3050) did not
// widen the predicate into a false positive.
assert.strictEqual(isSpawnTimeout({ error: null }), false);
// End-to-end: mock spawnSync to return the externally-killed shape and
// confirm the real execGit seam reports timedOut:false. Same
// mock.method(childProcess, 'spawnSync', ...) technique already
// established in shell-command-projection-dispatch.test.cjs for the
// Windows-shaped-timeout case, exercising the same non-destructured
// childProcess import for the opposite direction.
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: 'SIGTERM',
error: null,
}));
t.after(() => mock.restoreAll());
const result = execGit(['status', '--porcelain']);
assert.strictEqual(result.signal, 'SIGTERM');
assert.strictEqual(result.timedOut, false);
});
test('6. execGit env opt actually reaches the child process', (t) => {
tmpDir = createTempGitProject();
t.after(() => cleanup(tmpDir));
// worktree-safety.cts's execGitDefault is now a thin passthrough to this
// exact seam (see worktree-safety.cts:38 docstring), and its widened opts
// type ({cwd, env, timeout} together) is proven at build time by the
// strict tsc build, not here. This test instead proves the runtime
// behavior the type describes: `git var GIT_EDITOR` reflects the
// GIT_EDITOR env var, so a sentinel value passed via opts.env must come
// back verbatim on stdout — a real proof env reaches the child, not a
// liveness check that passes regardless of whether env is wired through.
const result = execGit(['var', 'GIT_EDITOR'], {
cwd: tmpDir,
env: { GIT_EDITOR: 'fault-3056-sentinel-editor' },
timeout: GIT_VAR_PROBE_TIMEOUT_MS,
});
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(result.stdout, 'fault-3056-sentinel-editor');
});
test('7. exitCode is always a number', (t) => {
tmpDir = createTempGitProject();
t.after(() => cleanup(tmpDir));
const success = execGit(['status', '--porcelain'], { cwd: tmpDir });
const enoent = execTool('definitely-not-a-real-program-fault-3056', []);
const timeout = execGit(['status', '--porcelain'], { cwd: tmpDir, timeout: DETERMINISTIC_TIMEOUT_SENTINEL_MS });
assert.strictEqual(typeof success.exitCode, 'number');
assert.strictEqual(typeof enoent.exitCode, 'number');
assert.strictEqual(typeof timeout.exitCode, 'number');
});
test('8. one git stub satisfies every ExecGitFn seam', (t) => {
tmpDir = createTempDir();
t.after(() => cleanup(tmpDir));
// Single shared stub, deliberately configured with only the default
// benign passthrough — the point of this row is that ONE value is
// accepted everywhere, not what any one fault produces.
const sharedStub = makeFaultyGit();
// worktree-safety.cts:33 — via resolveWorktreeContext, the exported
// entry point that threads deps.execGit. Derived: existsSync:()=>false
// skips the has_local_planning shortcut, so resolveWorktreeLinkage runs;
// the shared stub's benign passthrough (exitCode:0, empty stdout) for
// both --git-dir and --git-common-dir makes them resolve to the SAME
// path (tmpDir), which is the main_worktree branch
// (src/worktree-safety.cts:196-200) — a `typeof === 'string'` check
// would still pass if a shape regrowth returned e.g. reason:'not_a_git_repo'.
const wsResult = worktreeSafety.resolveWorktreeContext(tmpDir, {
execGit: sharedStub,
existsSync: () => false,
});
assert.deepStrictEqual(wsResult, {
effectiveRoot: tmpDir,
mode: 'current_directory',
reason: 'main_worktree',
});
// git-base-branch.cts:32 — trySymbolicRef takes execGit directly as its
// second positional argument (no deps wrapper). Derived: the stub
// answers `git symbolic-ref ...` with exitCode:0 but empty stdout, and
// trySymbolicRef treats an empty stdout as "unset" regardless of exit
// code (src/git-base-branch.cts:105) — it must return null, not merely
// return without throwing.
assert.strictEqual(trySymbolicRef(tmpDir, sharedStub), null);
// worktree-base-ref.cts:88 — evaluateWorktreeBaseDegrade threads
// deps.execGit. Derived: `git rev-parse HEAD` answers exitCode:0 with
// empty stdout, which is the explicit exit0-empty-stdout branch
// (src/worktree-base-ref.cts:401-403) — shouldDegrade:false,
// reason:'no-head', headAbsenceVerified:false (NOT the exit-128
// "definitive no-head" case, which would report headAbsenceVerified:true).
// A `typeof shouldDegrade === 'boolean'` check would still pass on a
// fail-closed flip to shouldDegrade:true.
const wbrResult = evaluateWorktreeBaseDegrade({ execGit: sharedStub, cwd: tmpDir });
assert.deepStrictEqual(wbrResult, {
shouldDegrade: false,
reason: 'no-head',
message: null,
headSha: null,
forkRef: null,
forkSha: null,
headAbsenceVerified: false,
});
// verification.cts:226 — defaultPhaseCleanCommitTimesMs takes execGitFn
// directly as its third positional argument, typed `= typeof execGit`.
// Derived: `git log ...` answers exitCode:0 with empty stdout, which
// trips the early `logRes.stdout.length === 0` return (empty Map) at
// src/verification.cts:247 — `map instanceof Map` alone would also pass
// for a non-empty map.
const map = defaultPhaseCleanCommitTimesMs(tmpDir, ['a.md', 'b.md'], sharedStub);
assert.deepStrictEqual(map, new Map());
});
});
// ─── B. FaultyGit ───────────────────────────────────────────────────────────
describe('B. FaultyGit', () => {
test('9. FaultyGit timeout trips isSpawnTimeout', () => {
const faultyGit = makeFaultyGit({ faults: [{ kind: 'timeout' }] });
const result = faultyGit(['status']);
assert.strictEqual(result.exitCode, 1);
assert.strictEqual(result.timedOut, true);
assert.strictEqual(result.error && result.error.code, 'ETIMEDOUT');
assert.strictEqual(isSpawnTimeout(result), true);
});
test('10. FaultyGit non-zero exit', () => {
const faultyGit = makeFaultyGit({ faults: [{ kind: 'exit', exitCode: 3, stderr: 'boom' }] });
const result = faultyGit(['status']);
assert.strictEqual(result.exitCode, 3);
assert.strictEqual(result.stderr, 'boom');
assert.strictEqual(result.timedOut, false);
});
test('11. FaultyGit spawn failure', () => {
const faultyGit = makeFaultyGit({ faults: [{ kind: 'spawnFail' }] });
const result = faultyGit(['status']);
assert.strictEqual(result.exitCode, 127);
assert.strictEqual(result.error && result.error.code, 'ENOENT');
assert.strictEqual(result.timedOut, false);
});
test('12. FaultyGit faults only what it was told to', () => {
const faultyGit = makeFaultyGit({
faults: [{ kind: 'timeout', when: ['worktree', 'list'] }],
});
const unmatched = faultyGit(['status', '--porcelain']);
assert.strictEqual(unmatched.timedOut, false);
assert.strictEqual(unmatched.exitCode, 0);
});
test('13. FaultyGit scopes a fault to one argv', () => {
const faultyGit = makeFaultyGit({
faults: [{ kind: 'exit', exitCode: 9, when: ['worktree', 'list'] }],
});
const matched = faultyGit(['worktree', 'list', '--porcelain']);
const unmatched = faultyGit(['status', '--porcelain']);
assert.strictEqual(matched.exitCode, 9);
assert.strictEqual(unmatched.exitCode, 0);
});
test('14. FaultyGit faults the Nth call only', () => {
const faultyGit = makeFaultyGit({
faults: [{ kind: 'exit', exitCode: 5, onCall: 2 }],
});
const first = faultyGit(['rev-parse', 'HEAD']);
const second = faultyGit(['rev-parse', 'HEAD']);
const third = faultyGit(['rev-parse', 'HEAD']);
assert.strictEqual(first.exitCode, 0);
assert.strictEqual(second.exitCode, 5);
assert.strictEqual(third.exitCode, 0);
});
test('15. FaultyGit records its calls', () => {
const faultyGit = makeFaultyGit();
faultyGit(['rev-parse', 'HEAD'], { cwd: '/a' });
faultyGit(['status'], { cwd: '/b' });
assert.strictEqual(faultyGit.calls.length, 2);
assert.deepStrictEqual(faultyGit.calls[0].args, ['rev-parse', 'HEAD']);
assert.strictEqual(faultyGit.calls[0].opts.cwd, '/a');
assert.deepStrictEqual(faultyGit.calls[1].args, ['status']);
assert.strictEqual(faultyGit.calls[1].opts.cwd, '/b');
});
test('16. FaultyGit result is a valid execGit result', () => {
const faultyGit = makeFaultyGit({ faults: [{ kind: 'timeout' }] });
const result = faultyGit(['status']);
for (const key of ['exitCode', 'stdout', 'stderr', 'signal', 'error', 'timedOut']) {
assert.ok(Object.prototype.hasOwnProperty.call(result, key), `missing ${key}`);
}
assert.strictEqual(typeof result.exitCode, 'number');
assert.strictEqual(typeof result.stdout, 'string');
assert.strictEqual(typeof result.stderr, 'string');
assert.strictEqual(typeof result.timedOut, 'boolean');
});
});
// ─── C. FaultyFs ────────────────────────────────────────────────────────────
describe('C. FaultyFs', () => {
test('17. FaultyFs read throws', () => {
const fs = require('node:fs');
const injected = new Error('injected read failure');
withFaultyFs({ readFileSync: () => { throw injected; } }, () => {
assert.throws(() => fs.readFileSync('/whatever'), /injected read failure/);
});
});
test('18. FaultyFs write throws', () => {
const fs = require('node:fs');
const injected = new Error('injected write failure');
withFaultyFs({ writeFileSync: () => { throw injected; } }, () => {
assert.throws(() => fs.writeFileSync('/whatever', 'x'), /injected write failure/);
});
});
test('19. FaultyFs restores on success', () => {
const fs = require('node:fs');
const original = fs.readFileSync;
withFaultyFs({ readFileSync: () => { throw new Error('injected'); } }, () => {
assert.notStrictEqual(fs.readFileSync, original);
});
assert.strictEqual(fs.readFileSync, original);
});
test('20. FaultyFs restores when the body throws', () => {
const fs = require('node:fs');
const original = fs.readFileSync;
assert.throws(() => {
withFaultyFs({ readFileSync: () => { throw new Error('injected'); } }, () => {
throw new Error('body exploded');
});
}, /body exploded/);
assert.strictEqual(fs.readFileSync, original);
});
test('21. nested FaultyFs restore in order', () => {
const fs = require('node:fs');
const original = fs.readFileSync;
const outerPatch = () => { throw new Error('outer'); };
const innerPatch = () => { throw new Error('inner'); };
withFaultyFs({ readFileSync: outerPatch }, () => {
assert.strictEqual(fs.readFileSync, outerPatch);
withFaultyFs({ readFileSync: innerPatch }, () => {
assert.strictEqual(fs.readFileSync, innerPatch);
});
// Inner restored WITHOUT clobbering the outer's still-active patch.
assert.strictEqual(fs.readFileSync, outerPatch);
});
assert.strictEqual(fs.readFileSync, original);
});
test('22. FaultyFs patches only the named method', () => {
const fs = require('node:fs');
const originalWrite = fs.writeFileSync;
withFaultyFs({ readFileSync: () => { throw new Error('injected'); } }, () => {
assert.strictEqual(fs.writeFileSync, originalWrite);
});
assert.strictEqual(fs.writeFileSync, originalWrite);
});
});