diff --git a/tests/bug-3441-path-action-projection.test.cjs b/tests/bug-3441-path-action-projection.test.cjs index 8ed8dbeaf..c61115fe1 100644 --- a/tests/bug-3441-path-action-projection.test.cjs +++ b/tests/bug-3441-path-action-projection.test.cjs @@ -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); } }); diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs new file mode 100644 index 000000000..92c054f63 --- /dev/null +++ b/tests/helpers-process-isolation.test.cjs @@ -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); + }); +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 9c9869dfc..077152fd4 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -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 };