/ src / launcher.test.ts
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  });