test: add process-state isolation helper for reliability (#331)

This commit is contained in:
Tom Boucher
2026-05-26 11:23:35 -04:00
committed by GitHub
parent 0a43185510
commit 656bff8b73
3 changed files with 100 additions and 29 deletions

View File

@@ -17,6 +17,7 @@ const projection = require(path.join(
'shell-command-projection.cjs',
));
const install = require(path.join(__dirname, '..', 'bin', 'install.js'));
const { withIsolatedProcessState } = require('./helpers.cjs');
function createTempHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-3441-'));
@@ -88,38 +89,37 @@ describe('bug #3441: PATH guidance is projected from typed shell action IR', ()
test('maybeSuggestPathExport renders commands projected by path-action seam', () => {
const home = createTempHome();
const originalPath = process.env.PATH;
try {
const globalBin = path.join(home, '.npm-global', 'bin');
fs.mkdirSync(globalBin, { recursive: true });
fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n');
process.env.PATH = '';
withIsolatedProcessState(() => {
const globalBin = path.join(home, '.npm-global', 'bin');
fs.mkdirSync(globalBin, { recursive: true });
fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n');
process.env.PATH = '';
const expected = projection.projectPathActionProjection({
mode: 'persist',
targetDir: globalBin,
platform: process.platform,
const expected = projection.projectPathActionProjection({
mode: 'persist',
targetDir: globalBin,
platform: process.platform,
});
const logs = [];
const originalLog = console.log;
console.log = (...args) => logs.push(args.join(' '));
try {
install.maybeSuggestPathExport(globalBin, home);
} finally {
console.log = originalLog;
}
const joined = logs.join('\n');
for (const action of expected.shellActions) {
assert.ok(
joined.includes(action.command),
`expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`,
);
}
});
const logs = [];
const originalLog = console.log;
console.log = (...args) => logs.push(args.join(' '));
try {
install.maybeSuggestPathExport(globalBin, home);
} finally {
console.log = originalLog;
}
const joined = logs.join('\n');
for (const action of expected.shellActions) {
assert.ok(
joined.includes(action.command),
`expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`,
);
}
} finally {
if (originalPath == null) delete process.env.PATH;
else process.env.PATH = originalPath;
cleanup(home);
}
});

View File

@@ -0,0 +1,41 @@
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { withIsolatedProcessState } = require('./helpers.cjs');
describe('withIsolatedProcessState', () => {
test('restores env, cwd, and exitCode after callback', () => {
const originalCwd = process.cwd();
const originalExitCode = process.exitCode;
const originalMarker = process.env.GSD_TEST_ISOLATION_MARKER;
const tempCwd = path.dirname(originalCwd);
withIsolatedProcessState(() => {
process.env.GSD_TEST_ISOLATION_MARKER = 'changed';
process.exitCode = 73;
process.chdir(tempCwd);
});
assert.strictEqual(process.cwd(), originalCwd);
assert.strictEqual(process.exitCode, originalExitCode);
assert.strictEqual(process.env.GSD_TEST_ISOLATION_MARKER, originalMarker);
});
test('restores state even when callback throws', () => {
const originalCwd = process.cwd();
const originalPath = process.env.PATH;
assert.throws(() => {
withIsolatedProcessState(() => {
process.env.PATH = '';
process.chdir(path.dirname(originalCwd));
throw new Error('boom');
});
}, /boom/);
assert.strictEqual(process.cwd(), originalCwd);
assert.strictEqual(process.env.PATH, originalPath);
});
});

View File

@@ -315,4 +315,34 @@ function isolatedNpmEnv() {
};
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, TOOLS_PATH };
/**
* Run a callback with process-level state isolation.
* Restores cwd, exitCode, and process.env after callback returns or throws.
*
* @template T
* @param {() => T} fn
* @returns {T}
*/
function withIsolatedProcessState(fn) {
const originalCwd = process.cwd();
const originalExitCode = process.exitCode;
const originalEnv = { ...process.env };
try {
return fn();
} finally {
if (process.cwd() !== originalCwd) {
process.chdir(originalCwd);
}
process.exitCode = originalExitCode;
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(originalEnv)) {
process.env[key] = value;
}
}
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, TOOLS_PATH };