Files
msd-core/tests/helpers/faulty-deps.cjs
Tom Boucher 7e1004d89e test(#3056): add the in-process fault-injection adapter, and normalize execGit's shape (#3077)
* 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>
2026-08-05 02:14:41 -04:00

200 lines
7.1 KiB
JavaScript

'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 };