diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..27b1d4a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Run Tests + +on: + pull_request: + branches: + - main + - master + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' # the project uses Node.js, version 20 is a good default + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..e3b61b3 --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerConfigCommand } from '../commands/config'; +import { setConfig, getConfig, clearConfig } from '../utils/config'; + +vi.mock('../utils/config', () => ({ + setConfig: vi.fn(), + getConfig: vi.fn(() => ({})), + clearConfig: vi.fn(), +})); + +describe('config command', () => { + let program: Command; + let consoleLogSpy: any; + let consoleErrorSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + program = new Command(); + registerConfigCommand(program); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should register config set, list, and clear commands', () => { + const configCmd = program.commands.find((c) => c.name() === 'config'); + expect(configCmd).toBeDefined(); + expect(configCmd?.commands.map((c) => c.name())).toEqual(['set', 'list', 'clear']); + }); + + it('should call setConfig on config set', async () => { + await program.parseAsync(['node', 'test', 'config', 'set', 'alert_email', 'test@test.com']); + expect(setConfig).toHaveBeenCalledWith('alert_email', 'test@test.com'); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Set alert_email to test@test.com')); + }); + + it('should parse integer for alert_cooldown', async () => { + await program.parseAsync(['node', 'test', 'config', 'set', 'alert_cooldown', '123']); + expect(setConfig).toHaveBeenCalledWith('alert_cooldown', 123); + }); + + it('should call getConfig on config list', async () => { + (getConfig as any).mockReturnValue({ alert_cooldown: 100 }); + await program.parseAsync(['node', 'test', 'config', 'list']); + expect(getConfig).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('alert_cooldown')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('100')); + }); + + it('should call clearConfig on config clear', async () => { + await program.parseAsync(['node', 'test', 'config', 'clear']); + expect(clearConfig).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Configuration cleared')); + }); +}); diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts new file mode 100644 index 0000000..0c524bd --- /dev/null +++ b/src/__tests__/health.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerHealthCommand } from '../commands/health'; +import { logger } from '../utils/logger'; + +vi.mock('../utils/logger', () => ({ + logger: { + info: vi.fn(), + }, +})); + +describe('health command', () => { + let program: Command; + + beforeEach(() => { + vi.clearAllMocks(); + program = new Command(); + registerHealthCommand(program); + }); + + it('should register health command', () => { + const healthCmd = program.commands.find((c) => c.name() === 'health'); + expect(healthCmd).toBeDefined(); + }); + + it('should call logger.info on health ', async () => { + await program.parseAsync(['node', 'test', 'health', 'all']); + expect(logger.info).toHaveBeenCalledWith('Showing health for all...'); + }); +}); diff --git a/src/__tests__/logs.test.ts b/src/__tests__/logs.test.ts new file mode 100644 index 0000000..240cc6f --- /dev/null +++ b/src/__tests__/logs.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerLogsCommand } from '../commands/logs'; +import { logger } from '../utils/logger'; + +vi.mock('../utils/logger', () => ({ + logger: { + info: vi.fn(), + }, +})); + +describe('logs command', () => { + let program: Command; + + beforeEach(() => { + vi.clearAllMocks(); + program = new Command(); + registerLogsCommand(program); + }); + + it('should register logs command', () => { + const logsCmd = program.commands.find((c) => c.name() === 'logs'); + expect(logsCmd).toBeDefined(); + }); + + it('should call logger.info on logs ', async () => { + await program.parseAsync(['node', 'test', 'logs', 'my-pod']); + expect(logger.info).toHaveBeenCalledWith('Showing logs for my-pod...'); + }); +}); diff --git a/src/__tests__/show.test.ts b/src/__tests__/show.test.ts new file mode 100644 index 0000000..27c3254 --- /dev/null +++ b/src/__tests__/show.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getRunningContainers } from '../docker/containers'; +import { getRunningPods } from '../kubernetes/pods'; +import { logger } from '../utils/logger'; +import { showRunners, showPods } from '../commands/show'; + +// Mock dependencies +vi.mock('../docker/containers', () => ({ + getRunningContainers: vi.fn(), +})); +vi.mock('../kubernetes/pods', () => ({ + getRunningPods: vi.fn(), +})); +vi.mock('../utils/logger', () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); +vi.mock('../ui/spinner', () => ({ + createSpinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); +vi.mock('../ui/table', () => ({ + renderTable: vi.fn(), +})); + +describe('show command runners', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should handle Kubernetes connection failure gracefully in showRunners', async () => { + // Setup: Docker succeeds, Kubernetes fails + (getRunningContainers as any).mockResolvedValue([{ id: '123', name: 'web', image: 'nginx', state: 'running', status: 'Up' }]); + (getRunningPods as any).mockRejectedValue(new Error('EHOSTUNREACH')); + + await showRunners(); + + // Verify: Warning was logged for Kubernetes, but no crash + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Kubernetes is unreachable')); + // Verify: Table still rendered with Docker data + const { renderTable } = await import('../ui/table'); + expect(renderTable).toHaveBeenCalled(); + }); + + it('should handle both services failing gracefully in showRunners', async () => { + (getRunningContainers as any).mockRejectedValue(new Error('Docker down')); + (getRunningPods as any).mockRejectedValue(new Error('K8s down')); + + await showRunners(); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Docker is unreachable')); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Kubernetes is unreachable')); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('No running containers or pods found')); + }); + + it('should handle Kubernetes connection failure gracefully in showPods', async () => { + (getRunningPods as any).mockRejectedValue(new Error('EHOSTUNREACH')); + + // Should not throw + await expect(showPods()).resolves.not.toThrow(); + }); +}); diff --git a/src/__tests__/watch.test.ts b/src/__tests__/watch.test.ts new file mode 100644 index 0000000..05e6dee --- /dev/null +++ b/src/__tests__/watch.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerWatchCommand } from '../commands/watch'; +import { render } from 'ink'; + +// Mock process.stdout.write +const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + +vi.mock('ink', () => ({ + render: vi.fn(), +})); + +vi.mock('../ui/WatchDashboard', () => ({ + WatchDashboard: () => null, +})); + +describe('watch command', () => { + let program: Command; + + beforeEach(() => { + vi.clearAllMocks(); + program = new Command(); + registerWatchCommand(program); + }); + + it('should register watch command', () => { + const watchCmd = program.commands.find((c) => c.name() === 'watch'); + expect(watchCmd).toBeDefined(); + }); + + it('should clear screen and render dashboard on watch', async () => { + await program.parseAsync(['node', 'test', 'watch']); + expect(stdoutWriteSpy).toHaveBeenCalledWith('\x1Bc'); + expect(render).toHaveBeenCalled(); + }); +}); diff --git a/src/commands/show.ts b/src/commands/show.ts index e68f4a2..8cf997e 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -26,58 +26,80 @@ export const registerShowCommand = (program: Command) => { }); }; -const showContainers = async () => { +export const showContainers = async () => { const spinner = createSpinner('Fetching Docker containers...').start(); - const containers = await getRunningContainers(); - spinner.stop(); + try { + const containers = await getRunningContainers(); + spinner.stop(); - if (containers.length === 0) { - logger.warn('No running Docker containers found or Docker is not running.'); - return; - } + if (containers.length === 0) { + logger.warn('No running Docker containers found.'); + return; + } - renderTable({ - head: ['CONTAINER ID', 'NAME', 'IMAGE', 'STATUS', 'STATE'], - rows: containers.map((c) => [ - c.id, - c.name, - c.image.substring(0, 30) + (c.image.length > 30 ? '...' : ''), - c.status, - c.state === 'running' ? chalk.green(c.state) : chalk.red(c.state), - ]), - }); + renderTable({ + head: ['CONTAINER ID', 'NAME', 'IMAGE', 'STATUS', 'STATE'], + rows: containers.map((c) => [ + c.id, + c.name, + c.image.substring(0, 30) + (c.image.length > 30 ? '...' : ''), + c.status, + c.state === 'running' ? chalk.green(c.state) : chalk.red(c.state), + ]), + }); + } catch (error) { + spinner.stop(); + // Error is already logged by getRunningContainers + } }; -const showPods = async () => { +export const showPods = async () => { const spinner = createSpinner('Fetching Kubernetes pods...').start(); - const pods = await getRunningPods(); - spinner.stop(); + try { + const pods = await getRunningPods(); + spinner.stop(); - if (pods.length === 0) { - logger.warn('No running Kubernetes pods found or cluster is unreachable.'); - return; - } + if (pods.length === 0) { + logger.warn('No running Kubernetes pods found.'); + return; + } - renderTable({ - head: ['POD NAME', 'NAMESPACE', 'STATUS', 'RESTARTS', 'NODE'], - rows: pods.map((p) => [ - p.name, - p.namespace, - p.status === 'Running' ? chalk.green(p.status) : chalk.yellow(p.status), - p.restarts > 0 ? chalk.red(p.restarts) : chalk.green('0'), - p.node, - ]), - }); + renderTable({ + head: ['POD NAME', 'NAMESPACE', 'STATUS', 'RESTARTS', 'NODE'], + rows: pods.map((p) => [ + p.name, + p.namespace, + p.status === 'Running' ? chalk.green(p.status) : chalk.yellow(p.status), + p.restarts > 0 ? chalk.red(p.restarts) : chalk.green('0'), + p.node, + ]), + }); + } catch (error) { + spinner.stop(); + // Error is already logged by getRunningPods + } }; -const showRunners = async () => { +export const showRunners = async () => { const spinner = createSpinner('Fetching runners (Containers + Pods)...').start(); - const [containers, pods] = await Promise.all([ + + const [containerRes, podRes] = await Promise.allSettled([ getRunningContainers(), getRunningPods() ]); + spinner.stop(); + const containers = containerRes.status === 'fulfilled' ? containerRes.value : []; + const pods = podRes.status === 'fulfilled' ? podRes.value : []; + + if (containerRes.status === 'rejected') { + logger.warn('Docker is unreachable, showing only Kubernetes pods (if any).'); + } + if (podRes.status === 'rejected') { + logger.warn('Kubernetes is unreachable, showing only Docker containers (if any).'); + } + if (containers.length === 0 && pods.length === 0) { logger.warn('No running containers or pods found.'); return; diff --git a/src/docker/containers.ts b/src/docker/containers.ts index e91cbf6..eb3486d 100644 --- a/src/docker/containers.ts +++ b/src/docker/containers.ts @@ -51,7 +51,8 @@ export const getRunningContainers = async (): Promise => { }; }); } catch (error) { - logger.error(`Failed to fetch Docker containers: ${(error as Error).message}`); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Failed to fetch Docker containers: ${errorMessage}`); // Throw error so UI can handle it instead of showing empty list throw error; } diff --git a/src/kubernetes/pods.ts b/src/kubernetes/pods.ts index 6838c21..d5bbfc6 100644 --- a/src/kubernetes/pods.ts +++ b/src/kubernetes/pods.ts @@ -54,7 +54,8 @@ export const getRunningPods = async (): Promise => { }; }); } catch (error) { - logger.error(`Failed to fetch Kubernetes pods: ${(error as Error).message}`); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Failed to fetch Kubernetes pods: ${errorMessage}`); throw error; } };