From 73039d371745ab90cae551e48b7dca7e6dbce50c Mon Sep 17 00:00:00 2001 From: Cody Anderson Date: Sat, 11 Jul 2026 20:13:10 -0600 Subject: [PATCH] =?UTF-8?q?fix(#2163):=20round-2=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20deterministic=20fail-soft=20injection=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readGitStatus calls execFileSync via the child_process namespace so tests can inject spawn failures through the shared module object - two deterministic tests: ERR_CHILD_PROCESS_STDOUT_MAXBUFFER-shaped and ETIMEDOUT-shaped throws both degrade to null (segment absent), proving the fail-soft paths the PR previously only asserted Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg --- hooks/gsd-statusline.js | 6 ++++-- tests/gsd-statusline.test.cjs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 6f718763a..c67ad8d02 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -6,7 +6,9 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +// Namespace (not destructured) so tests can inject spawn failures by +// monkeypatching childProcess.execFileSync. +const childProcess = require('child_process'); const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'); const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); @@ -339,7 +341,7 @@ function readGitStatus(dir) { // 8 MiB maxBuffer (default 1 MiB) headroom for repos with very many changed // or untracked files; overflow still degrades safely to segment-absent via // the catch below. - return execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'], + return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'], { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }); } catch (e) { return null; diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index c92264efc..f0ddf07d1 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -1500,6 +1500,41 @@ test('config-set statusline.show_context_tokens yes → rejected', () => { } }); + // Deterministic IO-failure injection (repo convention, cf. the fs + // monkeypatch in ensure-runtime-build.test.cjs): readGitStatus shares the + // one cached child_process module object, so replacing execFileSync here + // injects the failure without a real hang or oversized repo. + test('maxBuffer overflow degrades to null (segment absent)', () => { + const childProcess = require('node:child_process'); + const original = childProcess.execFileSync; + childProcess.execFileSync = () => { + const err = new RangeError('stdout maxBuffer length exceeded'); + err.code = 'ERR_CHILD_PROCESS_STDOUT_MAXBUFFER'; + throw err; + }; + try { + assert.equal(readGitStatus('/tmp'), null); + } finally { + childProcess.execFileSync = original; + } + }); + + test('spawn timeout degrades to null (segment absent)', () => { + const childProcess = require('node:child_process'); + const original = childProcess.execFileSync; + childProcess.execFileSync = () => { + const err = new Error('spawnSync git ETIMEDOUT'); + err.code = 'ETIMEDOUT'; + err.errno = -110; + throw err; + }; + try { + assert.equal(readGitStatus('/tmp'), null); + } finally { + childProcess.execFileSync = original; + } + }); + test('fresh repo with an untracked file is counted', () => { const { dir } = makeGitRepo(); try {