// @vitest-environment node import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearShellEnvCache, getCachedShellEnv, resolveInteractiveShellEnv, resolveInteractiveShellEnvBestEffort, } from '@main/utils/shellEnv'; const describePosix = process.platform === 'win32' ? describe.skip : describe; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForCachedEnv(timeoutMs = 2_000): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const cached = getCachedShellEnv(); if (cached) { return cached; } await sleep(25); } return getCachedShellEnv(); } async function createFakeShell(tempDir: string, name: string, source: string): Promise { const shellPath = path.join(tempDir, name); await writeFile(shellPath, `#!/usr/bin/env node\n${source}\n`, 'utf8'); await chmod(shellPath, 0o755); return shellPath; } function envWriterSource(envExpression: string): string { return ` function writeEnv(env) { process.stdout.write(Object.entries(env).map(([key, value]) => key + '=' + value).join('\\0') + '\\0'); } ${envExpression} `; } describePosix('shellEnv real child-process integration', () => { const originalShell = process.env.SHELL; const originalInvocationFile = process.env.FAKE_SHELL_INVOCATIONS; let tempDir = ''; beforeEach(async () => { clearShellEnvCache(); tempDir = await mkdtemp(path.join(tmpdir(), 'agent-teams-shell-env-')); delete process.env.FAKE_SHELL_INVOCATIONS; }); afterEach(async () => { clearShellEnvCache(); if (originalShell === undefined) { delete process.env.SHELL; } else { process.env.SHELL = originalShell; } if (originalInvocationFile === undefined) { delete process.env.FAKE_SHELL_INVOCATIONS; } else { process.env.FAKE_SHELL_INVOCATIONS = originalInvocationFile; } await rm(tempDir, { recursive: true, force: true }); }); it('returns a real shell env from an executable shell path before best-effort timeout', async () => { const fakeShell = await createFakeShell( tempDir, 'fast-shell.js', envWriterSource(` writeEnv({ PATH: '/fake-fast/bin:/usr/bin', HOME: '/fake-home', SHELL: process.argv[1], }); `) ); process.env.SHELL = fakeShell; const env = await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' }, }); expect(env).toMatchObject({ PATH: '/fake-fast/bin:/usr/bin', HOME: '/fake-home', SHELL: fakeShell, }); expect(getCachedShellEnv()).toMatchObject({ PATH: '/fake-fast/bin:/usr/bin', HOME: '/fake-home', }); }); it('returns fallback quickly while a slow shell warms the cache in the background', async () => { const fakeShell = await createFakeShell( tempDir, 'slow-shell.js', envWriterSource(` setTimeout(() => { writeEnv({ PATH: '/slow-real/bin:/usr/bin', HOME: '/slow-home', }); }, 200); `) ); process.env.SHELL = fakeShell; const startedAt = Date.now(); const env = await resolveInteractiveShellEnvBestEffort({ timeoutMs: 25, fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' }, }); const elapsedMs = Date.now() - startedAt; expect(elapsedMs).toBeLessThan(150); expect(env).toMatchObject({ PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' }); expect(getCachedShellEnv()).toBeNull(); await expect(waitForCachedEnv()).resolves.toMatchObject({ PATH: '/slow-real/bin:/usr/bin', HOME: '/slow-home', }); }); it('falls back from a failed login shell process to a successful interactive shell process', async () => { const fakeShell = await createFakeShell( tempDir, 'login-fails-shell.js', envWriterSource(` if ((process.argv[2] || '').includes('l')) { process.exit(42); } writeEnv({ PATH: '/interactive-real/bin:/usr/bin', HOME: '/interactive-home', }); `) ); process.env.SHELL = fakeShell; await expect(resolveInteractiveShellEnv()).resolves.toMatchObject({ PATH: '/interactive-real/bin:/usr/bin', HOME: '/interactive-home', }); expect(console.warn).toHaveBeenCalledWith( '[Utils:shellEnv]', 'Failed to resolve login shell env: shell env command exited with code 42' ); vi.mocked(console.warn).mockClear(); expect(getCachedShellEnv()).toMatchObject({ PATH: '/interactive-real/bin:/usr/bin', HOME: '/interactive-home', }); }); it('coalesces concurrent best-effort calls into one real shell process', async () => { const invocationFile = path.join(tempDir, 'invocations.log'); process.env.FAKE_SHELL_INVOCATIONS = invocationFile; const fakeShell = await createFakeShell( tempDir, 'coalesced-shell.js', envWriterSource(` const fs = require('fs'); fs.appendFileSync(process.env.FAKE_SHELL_INVOCATIONS, 'spawned\\n'); setTimeout(() => { writeEnv({ PATH: '/coalesced-real/bin:/usr/bin', HOME: '/coalesced-home', }); }, 200); `) ); process.env.SHELL = fakeShell; const results = await Promise.all( Array.from({ length: 10 }, async (_, index) => resolveInteractiveShellEnvBestEffort({ timeoutMs: 25, fallbackEnv: { PATH: `FALLBACK_${index}`, HOME: `FALLBACK_HOME_${index}` }, }) ) ); expect(results).toHaveLength(10); expect(results.every((env, index) => env.PATH === `FALLBACK_${index}`)).toBe(true); await expect(waitForCachedEnv()).resolves.toMatchObject({ PATH: '/coalesced-real/bin:/usr/bin', HOME: '/coalesced-home', }); const invocations = await readFile(invocationFile, 'utf8'); expect(invocations.trim().split('\n')).toHaveLength(1); }); });