Skip to content
29 changes: 29 additions & 0 deletions tests/commands/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,33 @@ describe('capabilities B3/B4', () => {
const keys = resources.keys as Array<{ keyType: string }>;
expect(keys.map((k) => k.keyType).sort()).toEqual(['disposable', 'permanent', 'timeLimit', 'urgent']);
});

it('--surface cli restricts surfaces block to cli only', async () => {
const out = await runCapabilitiesWith(['--surface', 'cli']);
const surfaces = out.surfaces as Record<string, unknown>;
expect(Object.keys(surfaces)).toEqual(['cli']);
});

it('--surface with invalid value throws CommanderError', async () => {
const program = makeProgram();
program.exitOverride();
registerCapabilitiesCommand(program);
await expect(
program.parseAsync(['node', 'test', 'capabilities', '--surface', 'bogus'])
).rejects.toThrow(/must be one of/i);
});

it('commandMeta entries all carry required safety fields', async () => {
const out = await runCapabilities();
const meta = out.commandMeta as Record<string, Record<string, unknown>>;
const entries = Object.values(meta);
expect(entries.length).toBeGreaterThan(50);
for (const entry of entries) {
expect(['read', 'action', 'destructive']).toContain(entry.agentSafetyTier);
expect(typeof entry.mutating).toBe('boolean');
expect(typeof entry.consumesQuota).toBe('boolean');
expect(['low', 'medium', 'high']).toContain(entry.riskLevel);
expect(['direct', 'plan', 'review-before-execute']).toContain(entry.recommendedMode);
}
});
});
116 changes: 116 additions & 0 deletions tests/commands/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,119 @@ describe('daemon command', () => {
);
});
});

describe('daemon stop', () => {
beforeEach(() => {
vi.useFakeTimers();
fsMock.unlinkSync.mockClear();
pidFileMock.readPidFile.mockReset().mockReturnValue(null);
pidFileMock.isPidAlive.mockReset().mockReturnValue(false);
daemonStateMock.readDaemonState.mockReset().mockReturnValue(null);
daemonStateMock.writeDaemonState.mockClear();
});

it('prints "No running daemon found" and persists stopped state when no daemon is running', async () => {
const res = await runCli(registerDaemonCommand, ['daemon', 'stop']);
expect(res.exitCode).toBeNull();
expect(res.stdout.join(' ')).toMatch(/no running daemon/i);
expect(daemonStateMock.writeDaemonState).toHaveBeenCalledWith(
expect.objectContaining({ status: 'stopped', pid: null }),
);
});

it('enters the running branch, unlinks pid files, and persists stopped state when daemon is alive', async () => {
pidFileMock.readPidFile.mockImplementation((file: string) =>
file === daemonStateMock.DAEMON_PID_FILE ? 12345 : null,
);
pidFileMock.isPidAlive.mockReturnValue(true);

// process.kill is called from killIfAlive; stub it so the test suite is
// not allowed to signal the test runner itself. The assertions below do
// not depend on the spy being hit — they check observable side effects
// (pid file unlink + persisted state + "stopped" message).
const origKill = process.kill;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process as any).kill = vi.fn(() => true);

const res = await runCli(registerDaemonCommand, ['daemon', 'stop']);

(process as any).kill = origKill;

expect(res.exitCode).toBeNull();
expect(fsMock.unlinkSync).toHaveBeenCalled();
expect(daemonStateMock.writeDaemonState).toHaveBeenLastCalledWith(
expect.objectContaining({ status: 'stopped', pid: null }),
);
expect(res.stdout.join(' ')).toMatch(/daemon stopped \(pid 12345\)/i);
});
});

describe('daemon status', () => {
beforeEach(() => {
pidFileMock.readPidFile.mockReset().mockReturnValue(null);
pidFileMock.isPidAlive.mockReset().mockReturnValue(false);
daemonStateMock.readDaemonState.mockReset().mockReturnValue(null);
});

it('--json reports status:stopped when no daemon is running', async () => {
const res = await runCli(registerDaemonCommand, ['--json', 'daemon', 'status']);
expect(res.exitCode).toBeNull();
const body = JSON.parse(res.stdout.join('')) as { data: { status: string; pid: unknown } };
expect(body.data.status).toBe('stopped');
expect(body.data.pid).toBeNull();
});

it('--json reports status:running with correct pid when daemon is alive', async () => {
pidFileMock.readPidFile.mockImplementation((file: string) =>
file === daemonStateMock.DAEMON_PID_FILE ? 9999 : null,
);
pidFileMock.isPidAlive.mockReturnValue(true);

const res = await runCli(registerDaemonCommand, ['--json', 'daemon', 'status']);
expect(res.exitCode).toBeNull();
const body = JSON.parse(res.stdout.join('')) as { data: { status: string; pid: number } };
expect(body.data.status).toBe('running');
expect(body.data.pid).toBe(9999);
});

it('human output prints "not running" when stopped', async () => {
const res = await runCli(registerDaemonCommand, ['daemon', 'status']);
expect(res.exitCode).toBeNull();
expect(res.stdout.join(' ')).toMatch(/not running/i);
});
});

describe('daemon reload', () => {
beforeEach(() => {
vi.useFakeTimers();
pidFileMock.readPidFile.mockReset().mockReturnValue(null);
pidFileMock.isPidAlive.mockReset().mockReturnValue(false);
daemonStateMock.readDaemonState.mockReset().mockReturnValue(null);
daemonStateMock.writeDaemonState.mockClear();
pidFileMock.writeReloadSentinel.mockClear();
pidFileMock.sighupSupported.mockReturnValue(false);
});

it('exits 2 with usage error when no daemon is running', async () => {
const res = await runCli(registerDaemonCommand, ['daemon', 'reload']);
expect(res.exitCode).toBe(2);
expect(res.stderr.join(' ')).toMatch(/no running daemon/i);
});

it('succeeds via sentinel when daemon and rules engine are running', async () => {
pidFileMock.readPidFile.mockImplementation((file: string) => {
if (file === daemonStateMock.DAEMON_PID_FILE) return 8888;
if (file === '/mock/.switchbot/rules.pid') return 7777;
return null;
});
pidFileMock.isPidAlive.mockReturnValue(true);

const res = await runCli(registerDaemonCommand, ['daemon', 'reload']);
expect(res.exitCode).toBeNull();
expect(pidFileMock.writeReloadSentinel).toHaveBeenCalledWith('/mock/.switchbot/rules.reload');
expect(daemonStateMock.writeDaemonState).toHaveBeenCalledWith(
expect.objectContaining({ lastReloadStatus: 'ok' }),
);
expect(res.stdout.join(' ')).toMatch(/reload requested/i);
});
});
78 changes: 78 additions & 0 deletions tests/commands/health-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, vi } from 'vitest';

const healthMock = vi.hoisted(() => {
const okReport = {
generatedAt: '2026-04-25T00:00:00.000Z',
overall: 'ok' as const,
process: { pid: 1234, uptimeSeconds: 60, platform: 'linux', nodeVersion: 'v18.0.0', memoryMb: 50 },
quota: { used: 100, limit: 10000, percentUsed: 1, remaining: 9900, status: 'ok' as const },
audit: { present: false, recentErrors: 0, recentTotal: 0, errorRatePercent: 0, status: 'ok' as const },
circuit: { name: 'switchbot-api', state: 'closed' as const, failures: 0, status: 'ok' as const },
};
return {
getHealthReport: vi.fn(() => okReport),
toPrometheusText: vi.fn(() => 'switchbot_quota_used_total 100\nswitchbot_circuit_open 0\n'),
okReport,
};
});

vi.mock('../../src/utils/health.js', () => ({
getHealthReport: healthMock.getHealthReport,
toPrometheusText: healthMock.toPrometheusText,
}));

import { registerHealthCommand } from '../../src/commands/health.js';
import { runCli } from '../helpers/cli.js';

describe('health check CLI', () => {
it('--json emits a structured health report with all components', async () => {
const res = await runCli(registerHealthCommand, ['--json', 'health', 'check']);
expect(res.exitCode).toBeNull();
const body = JSON.parse(res.stdout.join('')) as {
data: { overall: string; quota: unknown; audit: unknown; circuit: unknown; process: unknown };
};
expect(['ok', 'degraded', 'down']).toContain(body.data.overall);
expect(body.data.quota).toBeDefined();
expect(body.data.audit).toBeDefined();
expect(body.data.circuit).toBeDefined();
expect(body.data.process).toBeDefined();
});

it('exits 0 in human mode when overall is ok', async () => {
healthMock.getHealthReport.mockReturnValueOnce({ ...healthMock.okReport, overall: 'ok' });
const res = await runCli(registerHealthCommand, ['health', 'check']);
expect(res.exitCode).toBeNull();
});

it('exits 1 in human mode when overall is degraded', async () => {
healthMock.getHealthReport.mockReturnValueOnce({ ...healthMock.okReport, overall: 'degraded' });
const res = await runCli(registerHealthCommand, ['health', 'check']);
expect(res.exitCode).toBe(1);
});

it('exits 1 in human mode when overall is down', async () => {
healthMock.getHealthReport.mockReturnValueOnce({ ...healthMock.okReport, overall: 'down' });
const res = await runCli(registerHealthCommand, ['health', 'check']);
expect(res.exitCode).toBe(1);
});

it('--prometheus writes Prometheus text to process.stdout', async () => {
const written: string[] = [];
const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
written.push(String(chunk));
return true;
});
const res = await runCli(registerHealthCommand, ['health', 'check', '--prometheus']);
spy.mockRestore();
expect(res.exitCode).toBeNull();
expect(written.join('')).toContain('switchbot_quota_used_total');
expect(written.join('')).toContain('switchbot_circuit_open');
});

it('human mode output lists quota and circuit component rows', async () => {
const res = await runCli(registerHealthCommand, ['health', 'check']);
const out = res.stdout.join('\n');
expect(out).toMatch(/quota/i);
expect(out).toMatch(/circuit/i);
});
});
Loading
Loading