* refactor(#3071): normalize execGit's call and result shape, unify ExecGitFn ExecGitFn was declared four times. Three were hand-copies of one signature and two of those were wrong: they typed exitCode as number|null when _spawnResult returns `result.status ?? 1` and can never yield null, weakened signal from NodeJS.Signals to string, and widened error from Error to unknown. Only verification.cts got it right, via `typeof execGit`. The root cause was a missing export: SpawnResultOutput was declared without `export`, so no other module could name the return type of execGit. Three authors independently hand-copied it instead. Exported now. Normalizing the type alone would have left the pressure that caused the divergence, so the function is normalized on both sides. It now ACCEPTS every call its consumers make — worktree-safety's declaration could not express an env-carrying call at all — and RETURNS every result code they need: timedOut moves into _spawnResult, so execGit, execNpm and execTool all carry it and the one extension that justified a separate type disappears. All four sites are now `typeof execGit` with nothing left to restate. timedOut reuses the existing isSpawnTimeout predicate introduced by #3050 rather than re-deriving it. That predicate checks error.code === 'ETIMEDOUT' only; the signal === 'SIGTERM' conjunct was deliberately dropped there because Windows does not reliably report SIGTERM and requiring it risks a false negative. There is no false-positive risk, and a test proves it: an externally-delivered SIGTERM leaves error null, so it is still not reported as a timeout. No dead null-checks surfaced. Every exitCode comparison in the two affected modules is === 0, !== 0 or === 128 — never a null guard — so the nullable declaration had never been written against. Closes #3071 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3056): add the in-process fault-injection adapter Adds tests/helpers/faulty-deps.cjs — makeFaultyGit() and withFaultyFs() — so a module's error branch can be driven deterministically and its degraded verdict asserted, instead of a counter-test that only proves the call did not throw. makeFaultyGit returns a value structurally assignable to `typeof execGit`, so one stub satisfies all four seams that the #3071 normalization collapsed into that single shape. A parity test drives the same stub through a real injectable entry point in each of worktree-safety, git-base-branch, worktree-base-ref and verification; it fails the moment any of them re-grows its own shape. Faults are scoped rather than global — by argv predicate and by call ordinal — because a fault adapter that faults everything looks like it works and proves nothing, and because verification.cts's two-call error handling needs to fault the second call only. Invocations are recorded so a test can assert an exact call count. The timeout fault carries error.code === 'ETIMEDOUT', and a test asserts the real isSpawnTimeout predicate matches it, so the fixture cannot drift from the production definition of a timeout. A companion test asserts an externally delivered SIGTERM with a null error is still NOT reported as a timeout — the false-positive guard for #3050's dropped conjunct. withFaultyFs restores in a finally so a throwing body still restores, patches only the named methods, and nests without clobbering an outer saved original. It never uses chmod: that no-ops under root, so the test would pass with zero coverage in root Docker/CI. The adapter is in-process via deps only. The Phase 1 process seam is documented as deliberately not a fault-injection surface — it cannot distinguish an injected timeout from a genuine bench OOM and would retry it. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3071): add changeset fragment for the execGit normalization Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3071): route the last two timeout checks through the shared predicate An isolated review found this branch had normalised the timeout verdict but left two callers still hand-rolling the fragile version of it. check-command-router's runBoundedShell computed `timedOut: r.signal === 'SIGTERM'` while the correctly-derived `r.timedOut` sat on the same result object. graphify's execGraphify branched on `result.signal === 'SIGTERM'`, with a comment asserting the very premise isSpawnTimeout exists to reject. Both fail in both directions. On Windows a genuine timeout is not reliably reported as SIGTERM, so the guard silently fails to fire — the false negative #3050 was raised for. And an externally-delivered SIGTERM is not a timeout at all, so the check also fires when it should not; isSpawnTimeout avoids that because `error` is null in that case and it keys on error.code. Both now read the derived `timedOut`, and graphify's comment states the actual rule instead of the fragile assumption. Also replaces a vacuous test: "execGitDefault now accepts env" never called execGitDefault (it is unexported), called execGit — whose signature already accepted env before this branch — and asserted only that exitCode was a number, which would pass whether or not the change under test existed. It now proves env reaches the child by asserting `git var GIT_EDITOR` returns the injected sentinel. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3071): make the graphify timeout fixture faithful to a real timeout The remote matrix failed on both Linux lanes: graphify's "returns exitCode 124 on timeout" got 1 instead of 124. The fixture was wrong, not the production change. It stubbed spawnSync as { status: null, signal: 'SIGTERM', error: undefined }. That is not a timeout. A real spawnSync timeout also sets error.code 'ETIMEDOUT'; a SIGTERM with no error is an externally delivered signal — a kill. The old `result.signal === 'SIGTERM'` check accepted it as a timeout, which is the false positive the shared predicate exists to reject, so this test was locking that bug in rather than guarding against it. The fixture now carries a real ETIMEDOUT error and all three original assertions pass unchanged. A counter-test is added alongside it: an externally delivered SIGTERM with no error must NOT be reported as a timeout. That is the assertion whose absence let the false positive live. Swept every SIGTERM/SIGKILL stub under tests/ for the same unfaithful shape. No other instance: the worktree-safety, worktree-base-ref and commit-staging fixtures already set ETIMEDOUT, and the remaining hits are either deliberate external-kill tests or feed code that never consults timedOut. Two sites keep their own signal check deliberately and are NOT changed: capability-source.cts:1301,1386 fail closed on ANY abnormal termination, which is correct — reading timedOut there would stop it failing closed on a kill and let it parse stdout from a killed process. Their reason strings, and check-latest-version.cjs:115, label any signal as "timed out", which is imprecise wording over a correct verdict, not a silent failure. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3071): backfill changeset pr number to 3077 --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/quick-birds-munch.md
Normal file
5
.changeset/quick-birds-munch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3077
|
||||
---
|
||||
**`execGit` now reports `timedOut` on every result, and its return type is no longer misdeclared** — three modules hand-copied the shape of `execGit`'s result because the canonical type was not exported, and two of those copies declared `exitCode` as nullable when it can never be null. The shape is now declared once and reused, so a consumer can no longer be written against a contract the function does not honor. (#3071)
|
||||
@@ -968,7 +968,7 @@ function buildPredicateDeps() {
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
signal: r.signal,
|
||||
timedOut: r.signal === 'SIGTERM',
|
||||
timedOut: r.timedOut,
|
||||
};
|
||||
},
|
||||
findPhaseArtifact(phaseDir: string, artifactSuffix: string): string | null {
|
||||
|
||||
@@ -29,10 +29,7 @@ import { execGit as execGitSeam } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ExecGitFn = (
|
||||
args: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number }
|
||||
) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown };
|
||||
type ExecGitFn = typeof execGitSeam;
|
||||
|
||||
export interface BaseBranchDeps {
|
||||
/** Override the git runner (default: execGit from shell-command-projection) */
|
||||
|
||||
@@ -84,8 +84,12 @@ function execGraphify(cwd: string, args: string[], options: { timeout?: number }
|
||||
};
|
||||
}
|
||||
|
||||
// Timeout — seam exposes signal; spawnSync sets SIGTERM when killed by timeout.
|
||||
if (result.signal === 'SIGTERM') {
|
||||
// Timeout — result.timedOut is derived by the shared isSpawnTimeout predicate
|
||||
// (shell-command-projection.cts), keyed on error.code === 'ETIMEDOUT' rather
|
||||
// than signal === 'SIGTERM': Windows does not reliably report SIGTERM on a
|
||||
// timeout kill, and an externally-delivered SIGTERM (error is null) is not
|
||||
// a timeout at all.
|
||||
if (result.timedOut) {
|
||||
return {
|
||||
exitCode: 124,
|
||||
stdout: result.stdout,
|
||||
|
||||
@@ -491,12 +491,13 @@ export function projectPersistentPathExportActions({ targetDir, platform = proce
|
||||
|
||||
// ─── Subprocess dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
interface SpawnResultOutput {
|
||||
export interface SpawnResultOutput {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,14 +523,20 @@ export function isSpawnTimeout(result: { error?: unknown }): boolean {
|
||||
|
||||
function _spawnResult(result: { error?: NodeJS.ErrnoException | null; status?: number | null; stdout?: Buffer | string | null; stderr?: Buffer | string | null; signal?: NodeJS.Signals | null }, program: string): SpawnResultOutput {
|
||||
if (result.error && result.error.code === 'ENOENT') {
|
||||
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error };
|
||||
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error, timedOut: false };
|
||||
}
|
||||
const signal = result.signal ?? null;
|
||||
const error = result.error ?? null;
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: (result.stdout ?? '').toString().trim(),
|
||||
stderr: (result.stderr ?? '').toString().trim(),
|
||||
signal: result.signal ?? null,
|
||||
error: result.error ?? null,
|
||||
signal,
|
||||
error,
|
||||
// Reuse the single shared timeout predicate (isSpawnTimeout, below) rather
|
||||
// than re-deriving it here — see that function's docstring for why only
|
||||
// error.code === 'ETIMEDOUT' is checked (not signal === 'SIGTERM').
|
||||
timedOut: isSpawnTimeout({ error }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -85,10 +85,7 @@ function parseJsonc(text: string): unknown {
|
||||
|
||||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||
|
||||
type ExecGitFn = (
|
||||
args: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number }
|
||||
) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown };
|
||||
type ExecGitFn = typeof execGitSeam;
|
||||
|
||||
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execGit as execGitSeam, posixNormalize, isSpawnTimeout } from './shell-command-projection.cjs';
|
||||
import { execGit as execGitSeam, posixNormalize, type SpawnResultOutput } from './shell-command-projection.cjs';
|
||||
|
||||
// Default timeout for worktree-related git subprocess calls.
|
||||
// 10 s is generous enough for normal git operations on large repos while still
|
||||
@@ -21,31 +21,22 @@ const DEFAULT_GIT_TIMEOUT_MS = 10000;
|
||||
const WORKTREE_AGENT_BRANCH_RE = /^(worktree-)?agent-[A-Za-z0-9._/-]+$/;
|
||||
const WORKTREE_AGENT_BRANCH_PATTERN = WORKTREE_AGENT_BRANCH_RE.source;
|
||||
|
||||
interface GitResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
signal?: string | null;
|
||||
error?: NodeJS.ErrnoException | null;
|
||||
timedOut: boolean;
|
||||
}
|
||||
// GitResult now aliases the canonical SpawnResultOutput (shell-command-projection.cts),
|
||||
// which already carries `timedOut` — kept as a local name since it is referenced
|
||||
// below (gitResultOk).
|
||||
type GitResult = SpawnResultOutput;
|
||||
|
||||
type ExecGitFn = (args: string[], opts?: { cwd?: string; timeout?: number }) => GitResult;
|
||||
type ExecGitFn = typeof execGitSeam;
|
||||
|
||||
/**
|
||||
* Execute a git command via the shell-projection seam, with a derived
|
||||
* `timedOut` field. Tests inject mocks via deps.execGit using the new
|
||||
* Execute a git command via the shell-projection seam, applying the module's
|
||||
* default timeout. `timedOut` is now derived by the seam itself
|
||||
* (shell-command-projection.cts's `_spawnResult`), so this is a thin
|
||||
* passthrough. Tests inject mocks via deps.execGit using the same
|
||||
* (args, opts) shape — see worktree-safety-policy.test.cjs.
|
||||
*
|
||||
* Return shape: { exitCode, stdout, stderr, timedOut, error, signal }
|
||||
* - timedOut: derived via the shared `isSpawnTimeout` predicate
|
||||
* (shell-command-projection.cts) — true when spawnSync's `error.code`
|
||||
* is `ETIMEDOUT`; does not require `signal === 'SIGTERM'` (#3050).
|
||||
*/
|
||||
function execGitDefault(args: string[], opts: { cwd?: string; timeout?: number } = {}): GitResult {
|
||||
const result = execGitSeam(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
|
||||
const timedOut = isSpawnTimeout(result);
|
||||
return { ...result, timedOut };
|
||||
function execGitDefault(args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): GitResult {
|
||||
return execGitSeam(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
interface WorktreeBranchEntry {
|
||||
|
||||
322
tests/faulty-deps.test.cjs
Normal file
322
tests/faulty-deps.test.cjs
Normal file
@@ -0,0 +1,322 @@
|
||||
'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. 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: 1 });
|
||||
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: 5000,
|
||||
});
|
||||
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: 1 });
|
||||
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.
|
||||
const wsResult = worktreeSafety.resolveWorktreeContext(tmpDir, {
|
||||
execGit: sharedStub,
|
||||
existsSync: () => false,
|
||||
});
|
||||
assert.strictEqual(typeof wsResult.effectiveRoot, 'string');
|
||||
assert.strictEqual(typeof wsResult.mode, 'string');
|
||||
assert.strictEqual(typeof wsResult.reason, 'string');
|
||||
|
||||
// git-base-branch.cts:32 — trySymbolicRef takes execGit directly as its
|
||||
// second positional argument (no deps wrapper).
|
||||
assert.doesNotThrow(() => trySymbolicRef(tmpDir, sharedStub));
|
||||
|
||||
// worktree-base-ref.cts:88 — evaluateWorktreeBaseDegrade threads
|
||||
// deps.execGit.
|
||||
const wbrResult = evaluateWorktreeBaseDegrade({ execGit: sharedStub, cwd: tmpDir });
|
||||
assert.strictEqual(typeof wbrResult.shouldDegrade, 'boolean');
|
||||
assert.strictEqual(typeof wbrResult.reason, 'string');
|
||||
|
||||
// verification.cts:226 — defaultPhaseCleanCommitTimesMs takes execGitFn
|
||||
// directly as its third positional argument, typed `= typeof execGit`.
|
||||
assert.doesNotThrow(() => {
|
||||
const map = defaultPhaseCleanCommitTimesMs(tmpDir, ['a.md', 'b.md'], sharedStub);
|
||||
assert.ok(map instanceof 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);
|
||||
});
|
||||
});
|
||||
@@ -476,7 +476,10 @@ describe('build', () => {
|
||||
status: null,
|
||||
stdout: 'partial',
|
||||
stderr: '',
|
||||
error: undefined,
|
||||
// Real spawnSync timeouts set error.code === 'ETIMEDOUT'; the
|
||||
// timeout verdict keys on that, because SIGTERM alone is also
|
||||
// produced by an external kill and is unreliable on Windows.
|
||||
error: Object.assign(new Error('spawnSync ETIMEDOUT'), { code: 'ETIMEDOUT' }),
|
||||
signal: 'SIGTERM',
|
||||
}));
|
||||
|
||||
@@ -487,6 +490,22 @@ describe('build', () => {
|
||||
assert.strictEqual(result.timeout_ms, 30000);
|
||||
});
|
||||
|
||||
test('an externally-delivered SIGTERM is not reported as a timeout', () => {
|
||||
// Before the shared isSpawnTimeout predicate was adopted, this shape
|
||||
// (SIGTERM with no error.code) was misclassified as a timeout.
|
||||
mock.method(childProcess, 'spawnSync', () => ({
|
||||
status: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: undefined,
|
||||
signal: 'SIGTERM',
|
||||
}));
|
||||
|
||||
const result = execGraphify('/tmp', ['build']);
|
||||
assert.notStrictEqual(result.exitCode, 124);
|
||||
assert.notStrictEqual(result.reason, GRAPHIFY_REASON.TIMEOUT);
|
||||
});
|
||||
|
||||
test('passes PYTHONUNBUFFERED=1 in env', () => {
|
||||
let captured;
|
||||
mock.method(childProcess, 'spawnSync', (_cmd, _args, opts) => {
|
||||
|
||||
199
tests/helpers/faulty-deps.cjs
Normal file
199
tests/helpers/faulty-deps.cjs
Normal file
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* In-process fault-injection adapter (issue #3056, Phase 2 of epic #3051).
|
||||
*
|
||||
* `makeFaultyGit()` and `withFaultyFs()` inject faults ONLY via the `deps`
|
||||
* parameters that production seams already accept (`execGit`-shaped
|
||||
* functions, `fs` module methods). This is in-process injection ONLY.
|
||||
*
|
||||
* The Phase 1 subprocess-dispatch seam (`tests/helpers/process-seam.cjs`,
|
||||
* issue #3055) is DELIBERATELY NOT a fault-injection surface and must never
|
||||
* be used to simulate a timeout, an ENOENT, or any other fault: the
|
||||
* subprocess path has no way to distinguish an injected failure from a
|
||||
* genuine bench OOM or a real flaky CI host, and — per the process seam's
|
||||
* own retry policy — it would retry an injected timeout exactly as if it
|
||||
* were a real one, silently corrupting the test's intent (design matrix
|
||||
* row 24). Fault injection belongs at the `deps` seam, in-process, where the
|
||||
* test controls exactly what is faulted and exactly once.
|
||||
*
|
||||
* `makeFaultyGit(options)` returns a function structurally assignable to
|
||||
* `typeof execGit` from `shell-command-projection.cjs` — i.e. it satisfies
|
||||
* every `ExecGitFn` declaration in the tree (worktree-safety.cts,
|
||||
* git-base-branch.cts, worktree-base-ref.cts, verification.cts) because all
|
||||
* four are structural subsets/supersets of that one shape (#3071).
|
||||
*
|
||||
* `withFaultyFs(patches, body)` monkeypatches named `fs` methods and
|
||||
* guarantees restoration in a `finally` — this file is a standalone helper
|
||||
* with no test context, which is the ONLY place CONTRIBUTING.md ("Setup and
|
||||
* Cleanup") permits a `try/finally`: "try/finally is only permitted inside
|
||||
* standalone utility or helper functions that have no access to test
|
||||
* context." Test bodies must never use try/finally directly.
|
||||
*
|
||||
* NEVER use chmod to simulate an fs fault. `chmod 0o000` no-ops under root
|
||||
* (root bypasses mode bits), so a chmod-based fault silently passes with
|
||||
* zero coverage in root Docker/CI — see CONTRIBUTING.md and the Phase 2
|
||||
* design doc's "Not-corruption / negative space" section.
|
||||
*/
|
||||
|
||||
const DEFAULT_PASSTHROUGH_RESULT = Object.freeze({
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: null,
|
||||
error: null,
|
||||
timedOut: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the result shape for a single fault `kind`. Mirrors the shapes
|
||||
* `_spawnResult` in shell-command-projection.cts produces, so a faulted
|
||||
* result is indistinguishable in shape from a real one.
|
||||
*
|
||||
* @param {{kind: string, exitCode?: number, stderr?: string}} fault
|
||||
* @returns {{exitCode:number, stdout:string, stderr:string, signal:(string|null), error:(Error|null), timedOut:boolean}}
|
||||
*/
|
||||
function _buildFaultResult(fault) {
|
||||
switch (fault.kind) {
|
||||
case 'timeout': {
|
||||
const error = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' });
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: 'SIGTERM',
|
||||
error,
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
case 'exit': {
|
||||
return {
|
||||
exitCode: typeof fault.exitCode === 'number' ? fault.exitCode : 1,
|
||||
stdout: '',
|
||||
stderr: fault.stderr || '',
|
||||
signal: null,
|
||||
error: null,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
case 'spawnFail': {
|
||||
const error = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
|
||||
return {
|
||||
exitCode: 127,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: null,
|
||||
error,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`makeFaultyGit: unknown fault kind "${fault.kind}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when `fault.when` matches this invocation's argv.
|
||||
* `when` may be an argv predicate `(args) => boolean` or an array treated as
|
||||
* a prefix match against `args`.
|
||||
*
|
||||
* @param {*} when
|
||||
* @param {string[]} args
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _whenMatches(when, args) {
|
||||
if (when === undefined) return true;
|
||||
if (typeof when === 'function') return Boolean(when(args));
|
||||
if (Array.isArray(when)) {
|
||||
if (when.length > args.length) return false;
|
||||
return when.every((token, i) => args[i] === token);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fault-injecting stand-in for `execGit` (and, by structural typing,
|
||||
* for every `ExecGitFn` site in the tree — see module doc). Faults are
|
||||
* configured, never global: a call only faults when it matches every
|
||||
* constraint on a configured fault; every other call delegates to
|
||||
* `options.passthrough` (default: a benign zero-exit result).
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {Array<{
|
||||
* kind: 'timeout'|'exit'|'spawnFail',
|
||||
* exitCode?: number,
|
||||
* stderr?: string,
|
||||
* when?: ((args: string[]) => boolean) | string[],
|
||||
* onCall?: number,
|
||||
* }>} [options.faults] - fault configurations, evaluated in order; the first
|
||||
* whose `when`/`onCall` match the current call wins.
|
||||
* @param {(args: string[], opts?: object) => object} [options.passthrough] -
|
||||
* delegate for calls that do not match any configured fault. Defaults to a
|
||||
* benign `{exitCode:0, stdout:'', stderr:'', signal:null, error:null, timedOut:false}`.
|
||||
* @returns {((args: string[], opts?: object) => object) & { calls: Array<{args:string[], opts:object}> }}
|
||||
*/
|
||||
function makeFaultyGit(options) {
|
||||
const opts = options || {};
|
||||
const faults = Array.isArray(opts.faults) ? opts.faults : [];
|
||||
const passthrough = typeof opts.passthrough === 'function'
|
||||
? opts.passthrough
|
||||
: () => ({ ...DEFAULT_PASSTHROUGH_RESULT });
|
||||
|
||||
const calls = [];
|
||||
|
||||
function faultyGit(args, callOpts) {
|
||||
const recordedArgs = Array.isArray(args) ? args.slice() : args;
|
||||
const recordedOpts = callOpts || {};
|
||||
calls.push({ args: recordedArgs, opts: recordedOpts });
|
||||
const callNumber = calls.length;
|
||||
|
||||
for (const fault of faults) {
|
||||
if (fault.onCall !== undefined && fault.onCall !== callNumber) continue;
|
||||
if (!_whenMatches(fault.when, recordedArgs)) continue;
|
||||
return _buildFaultResult(fault);
|
||||
}
|
||||
|
||||
return passthrough(recordedArgs, recordedOpts);
|
||||
}
|
||||
|
||||
faultyGit.calls = calls;
|
||||
return faultyGit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monkeypatch named `fs` methods for the duration of `body()`, guaranteeing
|
||||
* restoration via `finally` even when `body` throws. Nestable: each call
|
||||
* saves its own originals into a local (not module-level) map, so an inner
|
||||
* `withFaultyFs` restoring first does not clobber an outer call's saved
|
||||
* original.
|
||||
*
|
||||
* This is the ONLY place in the test tree permitted to use try/finally —
|
||||
* see the module doc and CONTRIBUTING.md "Setup and Cleanup": "try/finally
|
||||
* is only permitted inside standalone utility or helper functions that have
|
||||
* no access to test context." This helper has no test context; it is a
|
||||
* plain function.
|
||||
*
|
||||
* @template T
|
||||
* @param {Record<string, (...args: any[]) => any>} patches - maps an `fs`
|
||||
* method name to its replacement (typically a function that throws).
|
||||
* @param {() => T} body
|
||||
* @returns {T}
|
||||
*/
|
||||
function withFaultyFs(patches, body) {
|
||||
const fs = require('node:fs');
|
||||
const methodNames = Object.keys(patches);
|
||||
const originals = new Map();
|
||||
for (const name of methodNames) {
|
||||
originals.set(name, fs[name]);
|
||||
fs[name] = patches[name];
|
||||
}
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
for (const name of methodNames) {
|
||||
fs[name] = originals.get(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { makeFaultyGit, withFaultyFs };
|
||||
Reference in New Issue
Block a user