Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
30 changes: 30 additions & 0 deletions src/__tests__/health.test.ts
Original file line number Diff line number Diff line change
@@ -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 <target>', async () => {
await program.parseAsync(['node', 'test', 'health', 'all']);
expect(logger.info).toHaveBeenCalledWith('Showing health for all...');
});
});
30 changes: 30 additions & 0 deletions src/__tests__/logs.test.ts
Original file line number Diff line number Diff line change
@@ -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 <name>', async () => {
await program.parseAsync(['node', 'test', 'logs', 'my-pod']);
expect(logger.info).toHaveBeenCalledWith('Showing logs for my-pod...');
});
});
67 changes: 67 additions & 0 deletions src/__tests__/show.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
36 changes: 36 additions & 0 deletions src/__tests__/watch.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
94 changes: 58 additions & 36 deletions src/commands/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/docker/containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export const getRunningContainers = async (): Promise<ContainerData[]> => {
};
});
} 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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/kubernetes/pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export const getRunningPods = async (): Promise<PodData[]> => {
};
});
} 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;
}
};
Loading