launcher.test.ts
1 import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 import type { ElectronAppEntry } from './electron-apps.js'; 3 import { detectProcess, discoverAppPath, launchDetachedApp, launchElectronApp, probeCDP, resolveExecutableCandidates } from './launcher.js'; 4 5 interface MockChildProcess { 6 once: ReturnType<typeof vi.fn>; 7 off: ReturnType<typeof vi.fn>; 8 unref: ReturnType<typeof vi.fn>; 9 emit: (event: string, value?: unknown) => void; 10 } 11 12 function createMockChildProcess(): MockChildProcess { 13 const listeners = new Map<string, Array<(value?: unknown) => void>>(); 14 15 return { 16 once: vi.fn((event: string, handler: (value?: unknown) => void) => { 17 listeners.set(event, [...(listeners.get(event) ?? []), handler]); 18 }), 19 off: vi.fn((event: string, handler: (value?: unknown) => void) => { 20 listeners.set(event, (listeners.get(event) ?? []).filter((listener) => listener !== handler)); 21 }), 22 unref: vi.fn(), 23 emit: (event: string, value?: unknown) => { 24 for (const listener of listeners.get(event) ?? []) listener(value); 25 }, 26 }; 27 } 28 29 vi.mock('node:child_process', () => ({ 30 execFileSync: vi.fn(), 31 spawn: vi.fn(), 32 })); 33 34 const cp = vi.mocked(await import('node:child_process')); 35 36 describe('probeCDP', () => { 37 it('returns false when CDP endpoint is unreachable', async () => { 38 const result = await probeCDP(59999, 500); 39 expect(result).toBe(false); 40 }); 41 }); 42 43 describe('detectProcess', () => { 44 beforeEach(() => { 45 vi.restoreAllMocks(); 46 }); 47 48 it('returns false when pgrep finds no process', () => { 49 cp.execFileSync.mockImplementation(() => { 50 const err = new Error('exit 1') as Error & { status: number }; 51 err.status = 1; 52 throw err; 53 }); 54 const result = detectProcess('NonExistentApp'); 55 expect(result).toBe(false); 56 }); 57 58 it.skipIf(process.platform === 'win32')('returns true when pgrep finds a process', () => { 59 cp.execFileSync.mockReturnValue('12345\n'); 60 const result = detectProcess('Cursor'); 61 expect(result).toBe(true); 62 }); 63 }); 64 65 describe('discoverAppPath', () => { 66 beforeEach(() => { 67 vi.restoreAllMocks(); 68 }); 69 70 it.skipIf(process.platform !== 'darwin')('returns path when osascript succeeds', () => { 71 cp.execFileSync.mockReturnValue('/Applications/Cursor.app/\n'); 72 const result = discoverAppPath('Cursor'); 73 expect(result).toBe('/Applications/Cursor.app'); 74 }); 75 76 it.skipIf(process.platform !== 'darwin')('returns null when osascript fails', () => { 77 cp.execFileSync.mockImplementation(() => { 78 throw new Error('app not found'); 79 }); 80 const result = discoverAppPath('NonExistent'); 81 expect(result).toBeNull(); 82 }); 83 84 it.skipIf(process.platform === 'darwin')('returns null on non-darwin platform', () => { 85 const result = discoverAppPath('Cursor'); 86 expect(result).toBeNull(); 87 }); 88 }); 89 90 describe('launchDetachedApp', () => { 91 beforeEach(() => { 92 vi.restoreAllMocks(); 93 cp.spawn.mockReset(); 94 }); 95 96 it('unrefs the process after spawn succeeds', async () => { 97 const child = createMockChildProcess(); 98 cp.spawn.mockImplementation(() => { 99 queueMicrotask(() => child.emit('spawn')); 100 return child as unknown as ReturnType<typeof cp.spawn>; 101 }); 102 103 await expect(launchDetachedApp('/Applications/Antigravity.app/Contents/MacOS/Antigravity', ['--remote-debugging-port=9234'], 'Antigravity')) 104 .resolves 105 .toBeUndefined(); 106 expect(child.unref).toHaveBeenCalledTimes(1); 107 }); 108 109 it('converts ENOENT into a controlled launch error', async () => { 110 const child = createMockChildProcess(); 111 cp.spawn.mockImplementation(() => { 112 queueMicrotask(() => child.emit('error', Object.assign(new Error('missing binary'), { code: 'ENOENT' }))); 113 return child as unknown as ReturnType<typeof cp.spawn>; 114 }); 115 116 await expect(launchDetachedApp('/Applications/Antigravity.app/Contents/MacOS/Antigravity', ['--remote-debugging-port=9234'], 'Antigravity')) 117 .rejects 118 .toThrow('Could not launch Antigravity'); 119 expect(child.unref).not.toHaveBeenCalled(); 120 }); 121 }); 122 123 describe('resolveExecutableCandidates', () => { 124 it('prefers explicit executable candidates over processName', () => { 125 const app: ElectronAppEntry = { 126 port: 9234, 127 processName: 'Antigravity', 128 executableNames: ['Electron', 'Antigravity'], 129 }; 130 131 expect(resolveExecutableCandidates('/Applications/Antigravity.app', app)).toEqual([ 132 '/Applications/Antigravity.app/Contents/MacOS/Electron', 133 '/Applications/Antigravity.app/Contents/MacOS/Antigravity', 134 ]); 135 }); 136 }); 137 138 describe('launchElectronApp', () => { 139 beforeEach(() => { 140 vi.restoreAllMocks(); 141 cp.spawn.mockReset(); 142 }); 143 144 it('falls back to the next executable candidate when the first is missing', async () => { 145 const firstChild = createMockChildProcess(); 146 const secondChild = createMockChildProcess(); 147 const app: ElectronAppEntry = { 148 port: 9234, 149 processName: 'Antigravity', 150 executableNames: ['Electron', 'Antigravity'], 151 }; 152 153 cp.spawn 154 .mockImplementationOnce(() => { 155 queueMicrotask(() => firstChild.emit('error', Object.assign(new Error('missing binary'), { code: 'ENOENT' }))); 156 return firstChild as unknown as ReturnType<typeof cp.spawn>; 157 }) 158 .mockImplementationOnce(() => { 159 queueMicrotask(() => secondChild.emit('spawn')); 160 return secondChild as unknown as ReturnType<typeof cp.spawn>; 161 }); 162 163 await expect( 164 launchElectronApp('/Applications/Antigravity.app', app, ['--remote-debugging-port=9234'], 'Antigravity'), 165 ).resolves.toBeUndefined(); 166 167 expect(cp.spawn).toHaveBeenNthCalledWith( 168 1, 169 '/Applications/Antigravity.app/Contents/MacOS/Electron', 170 ['--remote-debugging-port=9234'], 171 { detached: true, stdio: 'ignore' }, 172 ); 173 expect(cp.spawn).toHaveBeenNthCalledWith( 174 2, 175 '/Applications/Antigravity.app/Contents/MacOS/Antigravity', 176 ['--remote-debugging-port=9234'], 177 { detached: true, stdio: 'ignore' }, 178 ); 179 expect(secondChild.unref).toHaveBeenCalledTimes(1); 180 }); 181 });