* 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>
361 lines
16 KiB
JavaScript
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);
|
|
});
|
|
});
|