Files
msd-core/tests/faulty-deps.test.cjs
sim 9723d2b7e0 test(#3090): assert which value, not which type
Nine assertions drove a real fault through a real seam and then checked only
that the call did not throw, or that a result was a string, a boolean, an
array. Each passed whether the code was right or wrong. #3050 is the canonical
instance of the shape: its counter-test asserted effectiveRoot was a string and
never which root, so a silent misroute passed it.

All nine now assert the exact verdict, derived from the production branch each
one reaches and traced back to source rather than taken from the survey.

One was worse than a weak assertion. The test targeting resolveWorktreeLinkage's
main_worktree path used createTempGitProject, which always seeds .planning/ —
so the reason was always has_local_planning and the git-dir comparison the test
appears to exercise was unreachable from its own fixture. It was not asserting
loosely, it was pointed at the wrong path. The fixture now builds a git project
without .planning (projectDoc had to be disabled too, since it defaults to git
and would have re-seeded it), and the test reaches the branch it names.

Another had no reason assertion anywhere in the file while its four siblings all
pinned theirs — the odd one out rather than a convention.

The last is mine. The parity guard shipped in #3077 checked typeof and
doesNotThrow across four ExecGitFn seams, and that PR described it as failing
"the moment any site re-grows its own shape". It could not: a site returning a
different value of the same type passed it. All four benign-passthrough outputs
are derivable exact values, so it now asserts them and the claim is true.

Test names that promised more than their assertions established are corrected to
match what they prove.

Refs #3057

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:44:07 -04:00

350 lines
15 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. 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. 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);
});
});