import { describe, expect, it, vi } from 'vitest' import net from 'node:net' import http from 'node:http' import path from 'node:path' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { buildSidecarEnv, createAdapterPlan, createServerPlan, httpToWebSocketUrl, killSidecar, mergeProxyEnv, parseH5FixedPort, preferredServerPorts, proxyUrlFromElectronProxyRules, pushStartupLog, readH5FixedPort, readLastServerPort, reserveLocalPort, reserveServerPort, resolveHostTriple, spawnSidecar, waitForServer, windowsPowerShellOverride, writeLastServerPort, type SidecarChild, } from './sidecarManager' function fakeChild(pid = 4321) { return { pid, kill: vi.fn() } as unknown as SidecarChild & { kill: ReturnType } } function listen(server: http.Server, host = '127.0.0.1'): Promise { return new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, host, () => { const address = server.address() if (!address || typeof address === 'string') { reject(new Error('Could not resolve HTTP test port')) return } resolve(address.port) }) }) } function close(server: http.Server): Promise { return new Promise(resolve => server.close(() => resolve())) } describe('Electron sidecar manager', () => { it('maps host platform to existing sidecar target triples', () => { expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin') expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin') expect(resolveHostTriple('win32', 'x64')).toBe('x86_64-pc-windows-msvc') expect(resolveHostTriple('linux', 'arm64')).toBe('aarch64-unknown-linux-gnu') }) it('builds server sidecar args without changing the REST/WebSocket boundary', () => { const plan = createServerPlan({ desktopRoot: '/app/desktop', appRoot: '/app', port: 49321, env: {}, }) expect(plan.args).toEqual([ 'server', '--app-root', '/app', '--host', '0.0.0.0', '--port', '49321', ]) expect(plan.env.CLAUDE_H5_AUTO_PUBLIC_URL).toBe('1') expect(plan.env.CLAUDE_H5_DIST_DIR).toBe(path.join('/app/desktop', 'dist')) }) it('can keep sidecar binaries and H5 assets unpacked while pointing app-root at app.asar', () => { const plan = createServerPlan({ desktopRoot: '/Applications/App.app/Contents/Resources/app.asar.unpacked', appRoot: '/Applications/App.app/Contents/Resources/app.asar', h5DistDir: '/Applications/App.app/Contents/Resources/app.asar.unpacked/dist', port: 49321, env: {}, }) expect(plan.command).toContain('/Applications/App.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-') expect(plan.args).toContain('/Applications/App.app/Contents/Resources/app.asar') expect(plan.env.CLAUDE_H5_DIST_DIR).toBe('/Applications/App.app/Contents/Resources/app.asar.unpacked/dist') }) it('passes portable config and adapter server URL through the sidecar env', () => { const configDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-config-')) try { const env = buildSidecarEnv({ CLAUDE_CONFIG_DIR: configDir }, '/app/dist') expect(env.CLAUDE_CONFIG_DIR).toBe(configDir) expect(env.XDG_CACHE_HOME).toBe(path.join(configDir, 'Cache')) const adapter = createAdapterPlan({ desktopRoot: '/app/desktop', appRoot: '/app', serverUrl: 'http://127.0.0.1:4567', flag: '--telegram', env: { CLAUDE_CONFIG_DIR: configDir }, }) expect(adapter.env.ADAPTER_SERVER_URL).toBe('ws://127.0.0.1:4567') expect(adapter.args).toEqual(['adapters', '--app-root', '/app', '--telegram']) const whatsappAdapter = createAdapterPlan({ desktopRoot: '/app/desktop', appRoot: '/app', serverUrl: 'http://127.0.0.1:4567', flag: '--whatsapp', env: { CLAUDE_CONFIG_DIR: configDir }, }) expect(whatsappAdapter.args).toEqual(['adapters', '--app-root', '/app', '--whatsapp']) } finally { rmSync(configDir, { recursive: true, force: true }) } }) it('converts Electron system proxy rules into sidecar proxy env', () => { expect(proxyUrlFromElectronProxyRules('DIRECT')).toBeUndefined() expect(proxyUrlFromElectronProxyRules('SOCKS5 127.0.0.1:7891; DIRECT')).toBeUndefined() expect(proxyUrlFromElectronProxyRules('PROXY 127.0.0.1:7897; DIRECT')).toBe('http://127.0.0.1:7897') expect(proxyUrlFromElectronProxyRules('HTTPS proxy.example:8443; DIRECT')).toBe('https://proxy.example:8443') const env = mergeProxyEnv({}, 'http://127.0.0.1:7897') expect(env.HTTP_PROXY).toBe('http://127.0.0.1:7897') expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:7897') expect(env.http_proxy).toBe('http://127.0.0.1:7897') expect(env.https_proxy).toBe('http://127.0.0.1:7897') expect(env.NO_PROXY).toContain('127.0.0.1') expect(env.no_proxy).toContain('localhost') }) it('does not override explicit sidecar proxy environment and still preserves loopback bypasses', () => { const env = mergeProxyEnv( { HTTPS_PROXY: 'http://manual.example:8080', NO_PROXY: '.corp.local' }, 'http://system.example:8080', ) expect(env.HTTPS_PROXY).toBe('http://manual.example:8080') expect(env.HTTP_PROXY).toBeUndefined() expect(env.NO_PROXY).toBe('.corp.local,localhost,127.0.0.1,::1') expect(env.no_proxy).toBe('.corp.local,localhost,127.0.0.1,::1') }) it('keeps startup logs bounded', () => { const logs: string[] = [] for (let index = 0; index < 85; index++) { pushStartupLog(logs, `line ${index}`) } expect(logs).toHaveLength(80) expect(logs[0]).toBe('line 5') }) it('maps http urls to adapter websocket urls', () => { expect(httpToWebSocketUrl('http://127.0.0.1:3456')).toBe('ws://127.0.0.1:3456') expect(httpToWebSocketUrl('https://example.com')).toBe('wss://example.com') }) it('kills non-Windows sidecars with a signal', () => { const child = fakeChild() const spawnAsync = vi.fn() const spawnSyncFn = vi.fn() killSidecar(child, false, { platform: 'darwin', spawnAsync: spawnAsync as never, spawnSyncFn: spawnSyncFn as never }) expect(child.kill).toHaveBeenCalledTimes(1) expect(spawnAsync).not.toHaveBeenCalled() expect(spawnSyncFn).not.toHaveBeenCalled() }) it('uses async taskkill on Windows by default', () => { const child = fakeChild(777) const spawnAsync = vi.fn() const spawnSyncFn = vi.fn() killSidecar(child, false, { platform: 'win32', spawnAsync: spawnAsync as never, spawnSyncFn: spawnSyncFn as never }) expect(spawnAsync).toHaveBeenCalledWith('taskkill', ['/F', '/T', '/PID', '777'], { stdio: 'ignore', windowsHide: true }) expect(spawnSyncFn).not.toHaveBeenCalled() expect(child.kill).not.toHaveBeenCalled() }) it('uses synchronous taskkill on Windows during shutdown to avoid orphaned sidecars', () => { const child = fakeChild(777) const spawnAsync = vi.fn() const spawnSyncFn = vi.fn() killSidecar(child, true, { platform: 'win32', spawnAsync: spawnAsync as never, spawnSyncFn: spawnSyncFn as never }) expect(spawnSyncFn).toHaveBeenCalledWith('taskkill', ['/F', '/T', '/PID', '777'], { stdio: 'ignore', windowsHide: true }) expect(spawnAsync).not.toHaveBeenCalled() }) it('hides Windows console windows when launching sidecars', () => { const spawned = {} as SidecarChild const spawnFn = vi.fn(() => spawned) const existsSyncFn = vi.fn(() => true) const plan = { command: '/app/desktop/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe', args: ['server', '--port', '49321'], env: { CLAUDE_H5_AUTO_PUBLIC_URL: '1' }, } expect(spawnSidecar(plan, { existsSyncFn, spawnFn: spawnFn as never })).toBe(spawned) expect(existsSyncFn).toHaveBeenCalledWith(plan.command) expect(spawnFn).toHaveBeenCalledWith(plan.command, plan.args, { env: plan.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }) }) it('forwards a PowerShell shell choice to the sidecar only on Windows', () => { expect(windowsPowerShellOverride('pwsh.exe', 'win32')).toBe('pwsh.exe') expect(windowsPowerShellOverride('powershell.exe', 'win32')).toBe('powershell.exe') expect(windowsPowerShellOverride('C:\\tools\\PowerShell\\pwsh.exe', 'win32')).toBe('C:\\tools\\PowerShell\\pwsh.exe') // non-PowerShell selections must not be reported as a PowerShell override expect(windowsPowerShellOverride('cmd.exe', 'win32')).toBeNull() expect(windowsPowerShellOverride('C:\\bin\\bash.exe', 'win32')).toBeNull() expect(windowsPowerShellOverride(null, 'win32')).toBeNull() // never applies off Windows expect(windowsPowerShellOverride('pwsh', 'darwin')).toBeNull() expect(windowsPowerShellOverride('powershell.exe', 'linux')).toBeNull() }) it('parses only in-range integer h5Access.fixedPort values', () => { expect(parseH5FixedPort('{"h5Access":{"fixedPort":28670}}')).toBe(28670) expect(parseH5FixedPort('{"h5Access":{"fixedPort":80}}')).toBeNull() expect(parseH5FixedPort('{"h5Access":{"fixedPort":70000}}')).toBeNull() expect(parseH5FixedPort('{"h5Access":{"fixedPort":"3456"}}')).toBeNull() expect(parseH5FixedPort('{"h5Access":{"fixedPort":null}}')).toBeNull() expect(parseH5FixedPort('{"h5Access":{}}')).toBeNull() expect(parseH5FixedPort('{}')).toBeNull() expect(parseH5FixedPort('not json')).toBeNull() }) it('persists and prioritizes preferred server ports from the config dir', () => { const configDir = mkdtempSync(path.join(tmpdir(), 'cchh-server-state-')) const env = { CLAUDE_CONFIG_DIR: configDir } as NodeJS.ProcessEnv try { // Nothing stored yet: no preferred ports. expect(preferredServerPorts(env)).toEqual([]) // Sticky port from the previous run. writeLastServerPort(50123, env) expect(readLastServerPort(env)).toBe(50123) expect(preferredServerPorts(env)).toEqual([50123]) // An explicit fixed port wins over the sticky port. mkdirSync(path.join(configDir, 'cc-haha'), { recursive: true }) writeFileSync( path.join(configDir, 'cc-haha', 'settings.json'), JSON.stringify({ h5Access: { fixedPort: 28670 } }), 'utf-8', ) expect(readH5FixedPort(env)).toBe(28670) expect(preferredServerPorts(env)).toEqual([28670, 50123]) // Identical fixed and sticky ports are not duplicated. writeLastServerPort(28670, env) expect(preferredServerPorts(env)).toEqual([28670]) } finally { rmSync(configDir, { recursive: true, force: true }) } }) it('reserves a free preferred port and falls back when it is taken', async () => { // Reserve a random free port, verify preference picks it while free. const freePort = await reserveLocalPort('127.0.0.1') await expect(reserveServerPort('127.0.0.1', [freePort])).resolves.toBe(freePort) // Occupy it and verify the fallback hands out a different port. const blocker = net.createServer() await new Promise((resolve, reject) => { blocker.once('error', reject) blocker.listen(freePort, '127.0.0.1', () => resolve()) }) try { const fallback = await reserveServerPort('127.0.0.1', [freePort]) expect(fallback).not.toBe(freePort) } finally { await new Promise(resolve => blocker.close(() => resolve())) } // Invalid entries are skipped without throwing. await expect(reserveServerPort('127.0.0.1', [0, -1, 1.5, 70000])).resolves.toBeGreaterThan(0) }) it('does not treat a raw TCP accept as server readiness without healthy /health', async () => { const server = http.createServer((_request, response) => { response.writeHead(503, { 'content-type': 'application/json' }) response.end(JSON.stringify({ status: 'starting' })) }) const port = await listen(server) try { await expect(waitForServer('127.0.0.1', port, 300)).rejects.toThrow( /desktop server did not report healthy at http:\/\/127\.0\.0\.1:\d+\/health/, ) } finally { await close(server) } }) })