diff --git a/tests/unit/commands/docker-config-subcommand.test.ts b/tests/unit/commands/docker-config-subcommand.test.ts new file mode 100644 index 00000000..e71afd2d --- /dev/null +++ b/tests/unit/commands/docker-config-subcommand.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleConfig() { + const mod = await import( + `../../../src/commands/docker/config-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleConfig; +} + +describe('docker config subcommand', () => { + it('prints the derived Docker configuration table', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig; + dockerModule.DockerExecutor.prototype.getConfig = function () { + return { + host: 'docker-box', + remote: true, + ccsDir: '/tmp/.ccs', + dockerDir: '/tmp/docker', + composeFile: '/tmp/docker/docker-compose.integrated.yml', + dockerfile: '/tmp/docker/Dockerfile.integrated', + supervisordConfig: '/tmp/docker/supervisord.conf', + entrypoint: '/tmp/docker/entrypoint-integrated.sh', + remoteDeployDir: '~/.ccs/docker', + composeService: 'ccs-cliproxy', + containerName: 'ccs-cliproxy', + dashboardPort: 3000, + proxyPort: 8317, + }; + }; + + try { + const handleConfig = await loadHandleConfig(); + await handleConfig(['--host', 'docker-box']); + + const rendered = renderCapturedLines(capture.logLines); + expect(rendered).toContain('remote (docker-box)'); + expect(rendered).toContain('/tmp/docker/docker-compose.integrated.yml'); + expect(rendered).toContain('ccs-cliproxy'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig; + } + }); + + it('renders thrown config errors as boxed failures', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig; + dockerModule.DockerExecutor.prototype.getConfig = function () { + throw new Error('Missing bundled Docker asset: /tmp/docker/Dockerfile.integrated'); + }; + + try { + const handleConfig = await loadHandleConfig(); + await handleConfig([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Missing bundled Docker asset'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig; + } + }); +}); diff --git a/tests/unit/commands/docker-down-subcommand.test.ts b/tests/unit/commands/docker-down-subcommand.test.ts new file mode 100644 index 00000000..d511c5bb --- /dev/null +++ b/tests/unit/commands/docker-down-subcommand.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleDown() { + const mod = await import( + `../../../src/commands/docker/down-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleDown; +} + +describe('docker down subcommand', () => { + it('prints progress and success output for remote shutdowns', async () => { + const calls: Array<{ host?: string }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalDown = dockerModule.DockerExecutor.prototype.down; + dockerModule.DockerExecutor.prototype.down = function (options: { host?: string }) { + calls.push(options); + }; + + try { + const handleDown = await loadHandleDown(); + await handleDown(['--host', 'docker-box']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box' }]); + expect(rendered).toContain('Stopping Docker stack on docker-box...'); + expect(rendered).toContain('Docker stack stopped on docker-box.'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.down = originalDown; + } + }); + + it('renders executor failures as boxed errors', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalDown = dockerModule.DockerExecutor.prototype.down; + dockerModule.DockerExecutor.prototype.down = function () { + throw new Error('Docker stack shutdown failed.\nCommand timed out after 30s.'); + }; + + try { + const handleDown = await loadHandleDown(); + await handleDown([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Docker stack shutdown failed.'); + expect(rendered).toContain('Command timed out after 30s.'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.down = originalDown; + } + }); +}); diff --git a/tests/unit/commands/docker-logs-subcommand.test.ts b/tests/unit/commands/docker-logs-subcommand.test.ts new file mode 100644 index 00000000..ae2bbd7a --- /dev/null +++ b/tests/unit/commands/docker-logs-subcommand.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleLogs() { + const mod = await import( + `../../../src/commands/docker/logs-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleLogs; +} + +describe('docker logs subcommand', () => { + it('prints log snapshots returned by the executor', async () => { + const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalLogs = dockerModule.DockerExecutor.prototype.logs; + dockerModule.DockerExecutor.prototype.logs = async function (options: { + host?: string; + follow: boolean; + service?: 'ccs' | 'cliproxy'; + }) { + calls.push(options); + return '== ccs ==\nready'; + }; + + try { + const handleLogs = await loadHandleLogs(); + await handleLogs(['--service', 'ccs']); + + expect(calls).toEqual([{ follow: false, service: 'ccs' }]); + expect(renderCapturedLines(capture.logLines)).toContain('== ccs ==\nready'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.logs = originalLogs; + } + }); + + it('announces follow mode before streaming logs', async () => { + const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalLogs = dockerModule.DockerExecutor.prototype.logs; + dockerModule.DockerExecutor.prototype.logs = async function (options: { + host?: string; + follow: boolean; + service?: 'ccs' | 'cliproxy'; + }) { + calls.push(options); + }; + + try { + const handleLogs = await loadHandleLogs(); + await handleLogs(['--host', 'docker-box', '--follow', '--service', 'cliproxy']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box', follow: true, service: 'cliproxy' }]); + expect(rendered).toContain('Following Docker logs on docker-box...'); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.logs = originalLogs; + } + }); + + it('renders executor failures as boxed errors', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalLogs = dockerModule.DockerExecutor.prototype.logs; + dockerModule.DockerExecutor.prototype.logs = async function () { + throw new Error('Docker log retrieval failed.\nContainer is not running.'); + }; + + try { + const handleLogs = await loadHandleLogs(); + await handleLogs([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Docker log retrieval failed.'); + expect(rendered).toContain('Container is not running.'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.logs = originalLogs; + } + }); +}); diff --git a/tests/unit/commands/docker-status-subcommand.test.ts b/tests/unit/commands/docker-status-subcommand.test.ts index a7e65ba1..0fca60f7 100644 --- a/tests/unit/commands/docker-status-subcommand.test.ts +++ b/tests/unit/commands/docker-status-subcommand.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; let logLines: string[] = []; let errorLines: string[] = []; @@ -26,7 +26,6 @@ afterEach(() => { console.log = originalConsoleLog; console.error = originalConsoleError; process.exitCode = originalExitCode ?? 0; - mock.restore(); }); async function loadHandleStatus() { @@ -38,38 +37,42 @@ async function loadHandleStatus() { describe('docker status subcommand', () => { it('prints supervisor failure details instead of masking them as a missing container', async () => { - mock.module('../../../src/docker', () => ({ - DockerExecutor: class { - async status() { - return { - compose: { - command: '', - exitCode: 0, - stdout: 'NAME STATUS', - stderr: '', - remote: false, - }, - supervisor: { - command: '', - exitCode: 7, - stdout: '', - stderr: 'unix:///var/run/supervisor.sock no such file', - remote: false, - }, - }; - } - }, - })); + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalStatus = dockerModule.DockerExecutor.prototype.status; + dockerModule.DockerExecutor.prototype.status = function () { + return { + compose: { + command: '', + exitCode: 0, + stdout: 'NAME STATUS', + stderr: '', + remote: false, + }, + supervisor: { + command: '', + exitCode: 7, + stdout: '', + stderr: 'unix:///var/run/supervisor.sock no such file', + remote: false, + }, + }; + }; - const handleStatus = await loadHandleStatus(); - await handleStatus([]); + try { + const handleStatus = await loadHandleStatus(); + await handleStatus([]); - const rendered = logLines.join('\n'); - expect(rendered).toContain('Docker status'); - expect(rendered).toContain('Supervisor status check failed'); - expect(rendered).toContain('supervisor.sock no such file'); - expect(rendered).not.toContain('may not be running'); - expect(errorLines).toEqual([]); - expect(process.exitCode).toBe(0); + const rendered = logLines.join('\n'); + expect(rendered).toContain('Docker status'); + expect(rendered).toContain('Supervisor status check failed'); + expect(rendered).toContain('supervisor.sock no such file'); + expect(rendered).not.toContain('may not be running'); + expect(errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.status = originalStatus; + } }); }); diff --git a/tests/unit/commands/docker-subcommand-test-helpers.ts b/tests/unit/commands/docker-subcommand-test-helpers.ts new file mode 100644 index 00000000..6029bf00 --- /dev/null +++ b/tests/unit/commands/docker-subcommand-test-helpers.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, mock } from 'bun:test'; + +export function useDockerSubcommandConsoleCapture(): { + logLines: string[]; + errorLines: string[]; +} { + const state = { + logLines: [] as string[], + errorLines: [] as string[], + }; + + let originalConsoleLog: typeof console.log; + let originalConsoleError: typeof console.error; + let originalExitCode: number | undefined; + + beforeEach(() => { + state.logLines = []; + state.errorLines = []; + originalConsoleLog = console.log; + originalConsoleError = console.error; + originalExitCode = process.exitCode; + process.exitCode = 0; + + console.log = (...args: unknown[]) => { + state.logLines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + state.errorLines.push(args.map(String).join(' ')); + }; + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + process.exitCode = originalExitCode ?? 0; + mock.restore(); + }); + + return state; +} + +export function renderCapturedLines(lines: string[]): string { + return lines.join('\n'); +} diff --git a/tests/unit/commands/docker-up-subcommand.test.ts b/tests/unit/commands/docker-up-subcommand.test.ts new file mode 100644 index 00000000..7816cbc3 --- /dev/null +++ b/tests/unit/commands/docker-up-subcommand.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleUp() { + const mod = await import( + `../../../src/commands/docker/up-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleUp; +} + +describe('docker up subcommand', () => { + it('prints the success summary with the requested host and port mappings', async () => { + const calls: Array<{ host?: string; port: number; proxyPort: number }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalUp = dockerModule.DockerExecutor.prototype.up; + dockerModule.DockerExecutor.prototype.up = function (options: { + host?: string; + port: number; + proxyPort: number; + }) { + calls.push(options); + }; + + try { + const handleUp = await loadHandleUp(); + await handleUp(['--host', 'docker-box', '--port', '4000', '--proxy-port', '9317']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box', port: 4000, proxyPort: 9317 }]); + expect(rendered).toContain('Starting integrated Docker stack on docker-box'); + expect(rendered).toContain('Docker stack is running on docker-box.'); + expect(rendered).toContain('Dashboard port: 4000'); + expect(rendered).toContain('CLIProxy port: 9317'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.up = originalUp; + } + }); + + it('renders boxed validation errors without invoking the executor', async () => { + const handleUp = await loadHandleUp(); + await handleUp(['--port', '70000']); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Invalid value for --port'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/unit/commands/docker-update-subcommand.test.ts b/tests/unit/commands/docker-update-subcommand.test.ts new file mode 100644 index 00000000..240e3a37 --- /dev/null +++ b/tests/unit/commands/docker-update-subcommand.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleUpdate() { + const mod = await import( + `../../../src/commands/docker/update-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleUpdate; +} + +describe('docker update subcommand', () => { + it('prints progress and success output for remote updates', async () => { + const calls: Array<{ host?: string }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalUpdate = dockerModule.DockerExecutor.prototype.update; + dockerModule.DockerExecutor.prototype.update = function (options: { host?: string }) { + calls.push(options); + }; + + try { + const handleUpdate = await loadHandleUpdate(); + await handleUpdate(['--host', 'docker-box']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box' }]); + expect(rendered).toContain('Updating running Docker stack on docker-box...'); + expect(rendered).toContain('Docker stack updated on docker-box.'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.update = originalUpdate; + } + }); + + it('renders executor failures as boxed errors', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalUpdate = dockerModule.DockerExecutor.prototype.update; + dockerModule.DockerExecutor.prototype.update = function () { + throw new Error('Docker stack update failed.\nSupervisor restart did not complete.'); + }; + + try { + const handleUpdate = await loadHandleUpdate(); + await handleUpdate([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Docker stack update failed.'); + expect(rendered).toContain('Supervisor restart did not complete.'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.update = originalUpdate; + } + }); +});