// @vitest-environment node import { EventEmitter } from 'events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ spawn: vi.fn(), loggerWarn: vi.fn(), })); vi.mock('child_process', () => ({ spawn: hoisted.spawn, })); vi.mock('@main/utils/pathDecoder', () => ({ getHomeDir: () => '/Users/tester', })); vi.mock('@shared/utils/logger', () => ({ createLogger: () => ({ warn: hoisted.loggerWarn, error: vi.fn(), info: vi.fn(), debug: vi.fn(), }), })); class MockChildProcess extends EventEmitter { stdout = new EventEmitter(); kill = vi.fn(); } function createChild(): MockChildProcess { return new MockChildProcess(); } function emitEnv(child: MockChildProcess, env: Record): void { const dump = `${Object.entries(env) .map(([key, value]) => `${key}=${value}`) .join('\0')}\0`; child.stdout.emit('data', Buffer.from(dump)); child.emit('close', 0); } function emitEnvChunks(child: MockChildProcess, chunks: string[]): void { for (const chunk of chunks) { child.stdout.emit('data', Buffer.from(chunk)); } child.emit('close', 0); } function emitError(child: MockChildProcess, message: string): void { child.emit('error', new Error(message)); } function emitClose(child: MockChildProcess, code: number | null, signal: NodeJS.Signals | null): void { child.emit('close', code, signal); } async function importShellEnv(): Promise { return import('@main/utils/shellEnv'); } describe('shellEnv', () => { const originalPlatform = process.platform; const originalShell = process.env.SHELL; beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); vi.clearAllMocks(); process.env.SHELL = '/bin/zsh'; Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true, writable: true, }); }); afterEach(() => { vi.useRealTimers(); Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true, writable: true, }); if (originalShell === undefined) { delete process.env.SHELL; } else { process.env.SHELL = originalShell; } }); it('keeps the strict resolver login then interactive fallback order', async () => { const children: MockChildProcess[] = []; hoisted.spawn.mockImplementation(() => { const child = createChild(); children.push(child); queueMicrotask(() => { if (children.length === 1) { emitError(child, 'login failed'); } else { emitEnv(child, { PATH: '/interactive/bin', HOME: '/Users/tester' }); } }); return child; }); const shellEnv = await importShellEnv(); await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({ PATH: '/interactive/bin', HOME: '/Users/tester', }); expect(hoisted.spawn).toHaveBeenCalledTimes(2); expect(hoisted.spawn).toHaveBeenNthCalledWith( 1, '/bin/zsh', ['-lic', 'env -0'], expect.objectContaining({ windowsHide: true }) ); expect(hoisted.spawn).toHaveBeenNthCalledWith( 2, '/bin/zsh', ['-ic', 'env -0'], expect.objectContaining({ windowsHide: true }) ); }); it('returns fallback on soft timeout without caching it, then caches background success', async () => { const children: MockChildProcess[] = []; hoisted.spawn.mockImplementation(() => { const child = createChild(); children.push(child); return child; }); const shellEnv = await importShellEnv(); const fallbackEnv = { PATH: '/fallback/bin', HOME: '/fallback' }; const result = shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 10, fallbackEnv, }); await vi.advanceTimersByTimeAsync(10); await expect(result).resolves.toBe(fallbackEnv); expect(shellEnv.getCachedShellEnv()).toBeNull(); expect(hoisted.spawn).toHaveBeenCalledTimes(1); emitEnv(children[0], { PATH: '/real/bin', HOME: '/Users/tester' }); await Promise.resolve(); await Promise.resolve(); expect(shellEnv.getCachedShellEnv()).toMatchObject({ PATH: '/real/bin', HOME: '/Users/tester', }); }); it('returns real env when shell resolves before the soft timeout', async () => { const child = createChild(); hoisted.spawn.mockReturnValueOnce(child); const shellEnv = await importShellEnv(); const result = shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 100, fallbackEnv: { PATH: '/fallback/should-not-win' }, }); emitEnv(child, { PATH: '/fast/bin', HOME: '/Users/tester' }); await expect(result).resolves.toMatchObject({ PATH: '/fast/bin', HOME: '/Users/tester', }); expect(shellEnv.getCachedShellEnv()).toMatchObject({ PATH: '/fast/bin', HOME: '/Users/tester', }); }); it('does not let a soft fallback override getShellPreferredHome before cache warms', async () => { const child = createChild(); hoisted.spawn.mockReturnValueOnce(child); const shellEnv = await importShellEnv(); const result = shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 5, fallbackEnv: { PATH: '/fallback/bin', HOME: '/fallback-home' }, }); await vi.advanceTimersByTimeAsync(5); await expect(result).resolves.toMatchObject({ HOME: '/fallback-home' }); expect(shellEnv.getCachedShellEnv()).toBeNull(); expect(shellEnv.getShellPreferredHome()).toBe('/Users/tester'); emitEnv(child, { PATH: '/real/bin', HOME: '/real-home' }); await Promise.resolve(); await Promise.resolve(); expect(shellEnv.getShellPreferredHome()).toBe('/real-home'); }); it('parses chunked env output and ignores malformed records', async () => { const child = createChild(); hoisted.spawn.mockReturnValueOnce(child); const shellEnv = await importShellEnv(); const result = shellEnv.resolveInteractiveShellEnv(); emitEnvChunks(child, [ 'PATH=/chunk', 'ed/bin\0', 'MALFORMED\0', '=bad\0', 'EMPTY=\0', 'HOME=/Users/tester\0', ]); await expect(result).resolves.toMatchObject({ PATH: '/chunked/bin', EMPTY: '', HOME: '/Users/tester', }); expect(shellEnv.getCachedShellEnv()).toMatchObject({ PATH: '/chunked/bin', HOME: '/Users/tester', }); }); it('starts background resolution even with a zero soft timeout', async () => { const children: MockChildProcess[] = []; hoisted.spawn.mockImplementation(() => { const child = createChild(); children.push(child); return child; }); const shellEnv = await importShellEnv(); const fallbackEnv = { PATH: '/fallback/zero' }; await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 0, fallbackEnv, }) ).resolves.toBe(fallbackEnv); expect(hoisted.spawn).toHaveBeenCalledTimes(1); expect(shellEnv.getCachedShellEnv()).toBeNull(); emitEnv(children[0], { PATH: '/real/zero', HOME: '/Users/tester' }); await Promise.resolve(); await Promise.resolve(); expect(shellEnv.getCachedShellEnv()).toMatchObject({ PATH: '/real/zero', HOME: '/Users/tester', }); }); it('keeps resolving in the background through the strict interactive fallback', async () => { const children: MockChildProcess[] = []; hoisted.spawn.mockImplementation(() => { const child = createChild(); children.push(child); return child; }); const shellEnv = await importShellEnv(); const fallbackEnv = { PATH: '/fallback/login-timeout' }; const result = shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 10, fallbackEnv, }); await vi.advanceTimersByTimeAsync(10); await expect(result).resolves.toBe(fallbackEnv); expect(children).toHaveLength(1); emitError(children[0], 'login failed'); await Promise.resolve(); await Promise.resolve(); expect(children).toHaveLength(2); emitEnv(children[1], { PATH: '/interactive/bin', HOME: '/Users/tester' }); await Promise.resolve(); await Promise.resolve(); expect(shellEnv.getCachedShellEnv()).toMatchObject({ PATH: '/interactive/bin', HOME: '/Users/tester', }); }); it('treats non-zero shell exit with no env output as a failed probe', async () => { const children: MockChildProcess[] = []; hoisted.spawn.mockImplementation(() => { const child = createChild(); children.push(child); queueMicrotask(() => { if (children.length === 1) { emitClose(child, 42, null); } else { emitEnv(child, { PATH: '/interactive-after-exit/bin', HOME: '/Users/tester' }); } }); return child; }); const shellEnv = await importShellEnv(); await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({ PATH: '/interactive-after-exit/bin', HOME: '/Users/tester', }); expect(hoisted.spawn).toHaveBeenCalledTimes(2); expect(hoisted.loggerWarn).toHaveBeenCalledWith( 'Failed to resolve login shell env: shell env command exited with code 42' ); }); it('coalesces concurrent best-effort calls behind one shell process', async () => { const children: MockChildProcess[] = []; hoisted.spawn.mockImplementation(() => { const child = createChild(); children.push(child); return child; }); const shellEnv = await importShellEnv(); const firstFallback = { PATH: '/fallback/one' }; const secondFallback = { PATH: '/fallback/two' }; const first = shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 5, fallbackEnv: firstFallback, }); const second = shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 5, fallbackEnv: secondFallback, }); await vi.advanceTimersByTimeAsync(5); await expect(first).resolves.toBe(firstFallback); await expect(second).resolves.toBe(secondFallback); expect(hoisted.spawn).toHaveBeenCalledTimes(1); emitEnv(children[0], { PATH: '/real/bin' }); await Promise.resolve(); await Promise.resolve(); }); it('uses failure cooldown after a hard shell failure and avoids respawning immediately', async () => { hoisted.spawn.mockImplementation(() => { const child = createChild(); const callNumber = hoisted.spawn.mock.calls.length; queueMicrotask(() => emitError(child, `failure ${callNumber}`)); return child; }); const shellEnv = await importShellEnv(); const firstFallback = { PATH: '/fallback/first' }; const secondFallback = { PATH: '/fallback/second' }; await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: firstFallback, }) ).resolves.toBe(firstFallback); expect(hoisted.spawn).toHaveBeenCalledTimes(2); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: secondFallback, }) ).resolves.toBe(secondFallback); expect(hoisted.spawn).toHaveBeenCalledTimes(2); }); it('expires failure cooldown so a later best-effort call can retry shell resolution', async () => { hoisted.spawn.mockImplementation(() => { const child = createChild(); queueMicrotask(() => emitError(child, 'blocked')); return child; }); const shellEnv = await importShellEnv(); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: { PATH: '/fallback/first' }, }) ).resolves.toMatchObject({ PATH: '/fallback/first' }); expect(hoisted.spawn).toHaveBeenCalledTimes(2); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: { PATH: '/fallback/cooldown' }, }) ).resolves.toMatchObject({ PATH: '/fallback/cooldown' }); expect(hoisted.spawn).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(60_001); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: { PATH: '/fallback/retry' }, }) ).resolves.toMatchObject({ PATH: '/fallback/retry' }); expect(hoisted.spawn).toHaveBeenCalledTimes(4); }); it('terminates stuck login and interactive shell probes before returning fallback', async () => { const children: MockChildProcess[] = []; hoisted.spawn.mockImplementation(() => { const child = createChild(); children.push(child); return child; }); const shellEnv = await importShellEnv(); const result = shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 30_000, fallbackEnv: { PATH: '/fallback/stuck' }, }); await vi.advanceTimersByTimeAsync(12_000); await Promise.resolve(); await Promise.resolve(); expect(children).toHaveLength(2); expect(children[0].kill).toHaveBeenCalledWith(); await vi.advanceTimersByTimeAsync(12_000); await expect(result).resolves.toMatchObject({ PATH: '/fallback/stuck' }); expect(children[1].kill).toHaveBeenCalledWith(); expect(shellEnv.getCachedShellEnv()).toBeNull(); await vi.advanceTimersByTimeAsync(3_000); expect(children[0].kill).toHaveBeenCalledWith('SIGKILL'); expect(children[1].kill).toHaveBeenCalledWith('SIGKILL'); }); it('clears failure cooldown when the shell env cache is cleared', async () => { let fail = true; hoisted.spawn.mockImplementation(() => { const child = createChild(); queueMicrotask(() => { if (fail) { emitError(child, 'blocked'); } else { emitEnv(child, { PATH: '/recovered/bin', HOME: '/Users/tester' }); } }); return child; }); const shellEnv = await importShellEnv(); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: { PATH: '/fallback/blocked' }, }) ).resolves.toMatchObject({ PATH: '/fallback/blocked' }); expect(hoisted.spawn).toHaveBeenCalledTimes(2); fail = false; shellEnv.clearShellEnvCache(); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_000, fallbackEnv: { PATH: '/fallback/recovered' }, }) ).resolves.toMatchObject({ PATH: '/recovered/bin', HOME: '/Users/tester', }); expect(hoisted.spawn).toHaveBeenCalledTimes(3); }); it('uses cached shell env immediately without spawning or returning fallback', async () => { const firstChild = createChild(); hoisted.spawn.mockReturnValueOnce(firstChild); const shellEnv = await importShellEnv(); const strictResult = shellEnv.resolveInteractiveShellEnv(); emitEnv(firstChild, { PATH: '/cached/bin', HOME: '/Users/tester' }); await expect(strictResult).resolves.toMatchObject({ PATH: '/cached/bin' }); hoisted.spawn.mockClear(); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1, fallbackEnv: { PATH: '/fallback/should-not-win' }, }) ).resolves.toMatchObject({ PATH: '/cached/bin', HOME: '/Users/tester', }); expect(hoisted.spawn).not.toHaveBeenCalled(); }); it('strict resolver also returns cached shell env without spawning again', async () => { const firstChild = createChild(); hoisted.spawn.mockReturnValueOnce(firstChild); const shellEnv = await importShellEnv(); const first = shellEnv.resolveInteractiveShellEnv(); emitEnv(firstChild, { PATH: '/strict-cached/bin', HOME: '/Users/tester' }); await expect(first).resolves.toMatchObject({ PATH: '/strict-cached/bin' }); hoisted.spawn.mockClear(); await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({ PATH: '/strict-cached/bin', HOME: '/Users/tester', }); expect(hoisted.spawn).not.toHaveBeenCalled(); }); it('best-effort on win32 preserves the strict no-spawn behavior', async () => { Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, writable: true, }); const shellEnv = await importShellEnv(); await expect( shellEnv.resolveInteractiveShellEnvBestEffort({ timeoutMs: 1, fallbackEnv: { PATH: '/fallback/win32' }, }) ).resolves.toEqual({}); expect(hoisted.spawn).not.toHaveBeenCalled(); expect(shellEnv.getCachedShellEnv()).toEqual({}); }); });