import { describe, expect, it } from 'bun:test'; import { DockerExecutor } from '../../../src/docker/docker-executor'; import type { DockerCommandResult } from '../../../src/docker/docker-types'; const fakeAssets = { dockerDir: '/tmp/ccs-docker', composeFile: '/tmp/ccs-docker/docker-compose.integrated.yml', dockerfile: '/tmp/ccs-docker/Dockerfile.integrated', supervisordConfig: '/tmp/ccs-docker/supervisord.conf', entrypoint: '/tmp/ccs-docker/entrypoint-integrated.sh', }; type SyncCall = { command: string; args: string[]; options?: { cwd?: string; env?: NodeJS.ProcessEnv; remote?: boolean; timeoutMs?: number; }; }; function okResult(remote = false): DockerCommandResult { return { command: '', exitCode: 0, stdout: '', stderr: '', remote, }; } describe('docker executor', () => { it('passes compose env and bundled compose file when bringing the stack up locally', async () => { const calls: SyncCall[] = []; const executor = new DockerExecutor({ assets: fakeAssets, getInstalledCcsVersion: () => '7.59.0', resolveLocalComposePrefix: () => ['docker', 'compose'], runSync: (command, args, options) => { calls.push({ command, args, options }); return okResult(options?.remote ?? false); }, }); await executor.up({ port: 4000, proxyPort: 9317 }); expect(calls).toHaveLength(1); expect(calls[0].command).toBe('docker'); expect(calls[0].args).toEqual(['compose', '-f', fakeAssets.composeFile, 'up', '-d', '--build']); expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir); expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0'); expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000'); expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317'); expect(calls[0].options?.timeoutMs).toBe(300_000); }); it('stages bundled assets before remote compose startup', async () => { const calls: SyncCall[] = []; const executor = new DockerExecutor({ assets: fakeAssets, getInstalledCcsVersion: () => '7.59.0', runSync: (command, args, options) => { calls.push({ command, args, options }); return okResult(options?.remote ?? false); }, }); await executor.up({ host: 'docker', port: 3000, proxyPort: 8317 }); expect(calls).toHaveLength(3); expect(calls[0]).toEqual({ command: 'ssh', args: ['docker', 'mkdir -p ~/.ccs/docker'], options: { remote: true, timeoutMs: 30_000 }, }); expect(calls[1].command).toBe('scp'); expect(calls[1].args).toEqual([ fakeAssets.composeFile, fakeAssets.dockerfile, fakeAssets.supervisordConfig, fakeAssets.entrypoint, 'docker:~/.ccs/docker/', ]); expect(calls[1].options).toEqual({ remote: true, timeoutMs: 30_000 }); expect(calls[2].command).toBe('ssh'); expect(calls[2].args[0]).toBe('docker'); expect(calls[2].args[1]).toContain("export CCS_NPM_VERSION='7.59.0'"); expect(calls[2].args[1]).toContain("export CCS_DASHBOARD_PORT='3000'"); expect(calls[2].args[1]).toContain("export CCS_CLIPROXY_PORT='8317'"); expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1'); expect(calls[2].options?.timeoutMs).toBe(300_000); }); it('uses npm install latest rather than npm update during in-container updates', async () => { const calls: SyncCall[] = []; const executor = new DockerExecutor({ assets: fakeAssets, runSync: (command, args, options) => { calls.push({ command, args, options }); return okResult(options?.remote ?? false); }, }); await executor.update({}); expect(calls).toHaveLength(1); expect(calls[0].command).toBe('docker'); expect(calls[0].args[0]).toBe('exec'); expect(calls[0].args[1]).toBe('ccs-cliproxy'); expect(calls[0].args[4]).toContain('npm install -g @kaitranntt/ccs@latest --force'); expect(calls[0].args[4]).toContain('ccs cliproxy --latest'); expect(calls[0].args[4]).toContain( 'supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy' ); }); it('preserves supervisorctl failures in status results for CLI rendering', async () => { let callCount = 0; const executor = new DockerExecutor({ assets: fakeAssets, resolveLocalComposePrefix: () => ['docker', 'compose'], runSync: (_command, args, options) => { callCount++; if (callCount === 1) { expect(args).toEqual(['compose', '-f', fakeAssets.composeFile, 'ps']); return { command: '', exitCode: 0, stdout: 'NAME STATUS', stderr: '', remote: options?.remote ?? false, }; } expect(args).toEqual([ 'exec', 'ccs-cliproxy', 'supervisorctl', '-c', '/etc/supervisord.conf', 'status', ]); return { command: '', exitCode: 7, stdout: '', stderr: 'unix:///var/run/supervisor.sock no such file', remote: options?.remote ?? false, }; }, }); const status = await executor.status({}); expect(status.compose.exitCode).toBe(0); expect(status.supervisor?.exitCode).toBe(7); expect(status.supervisor?.stderr).toContain('supervisor.sock'); }); it('surfaces a clear timeout message for blocked sync commands', () => { const executor = new DockerExecutor({ assets: fakeAssets, }); const result = ( executor as unknown as { runSync: ( command: string, args: string[], options?: { remote?: boolean; timeoutMs?: number } ) => DockerCommandResult; } ).runSync(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { remote: true, timeoutMs: 25, }); expect(result.exitCode).toBe(1); expect(result.stderr).toContain( 'Command timed out after 25ms while running a remote Docker command.' ); expect(result.stderr).toContain(`Command: ${process.execPath}`); expect(result.stderr).toContain('Check SSH reachability and the remote Docker host'); }); });