test: add process-state isolation helper for reliability (#331)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
41
tests/helpers-process-isolation.test.cjs
Normal file
41
tests/helpers-process-isolation.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user