Move all container-runtime-specific logic (binary name, mount args, stop command, startup check, orphan cleanup) into a single file so swapping runtimes only requires replacing this one file. Neutralize "Apple Container" references in comments and docs that would become incorrect after a runtime swap. References that list both runtimes as options are left unchanged. No behavior change — Apple Container remains the default runtime. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
190 lines
5.3 KiB
TypeScript
190 lines
5.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock logger
|
|
vi.mock('./logger.js', () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock child_process — store the mock fn so tests can configure it
|
|
const mockExecSync = vi.fn();
|
|
vi.mock('child_process', () => ({
|
|
execSync: (...args: unknown[]) => mockExecSync(...args),
|
|
}));
|
|
|
|
import {
|
|
CONTAINER_RUNTIME_BIN,
|
|
readonlyMountArgs,
|
|
stopContainer,
|
|
ensureContainerRuntimeRunning,
|
|
cleanupOrphans,
|
|
} from './container-runtime.js';
|
|
import { logger } from './logger.js';
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// --- Pure functions ---
|
|
|
|
describe('readonlyMountArgs', () => {
|
|
it('returns --mount flag with bind,source,target,readonly', () => {
|
|
const args = readonlyMountArgs('/host/path', '/container/path');
|
|
expect(args).toEqual([
|
|
'--mount',
|
|
'type=bind,source=/host/path,target=/container/path,readonly',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('stopContainer', () => {
|
|
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
|
|
expect(stopContainer('nanoclaw-test-123')).toBe(
|
|
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- ensureContainerRuntimeRunning ---
|
|
|
|
describe('ensureContainerRuntimeRunning', () => {
|
|
it('does nothing when runtime is already running', () => {
|
|
// system status succeeds
|
|
mockExecSync.mockReturnValueOnce('');
|
|
|
|
ensureContainerRuntimeRunning();
|
|
|
|
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
expect(mockExecSync).toHaveBeenCalledWith(
|
|
`${CONTAINER_RUNTIME_BIN} system status`,
|
|
{ stdio: 'pipe' },
|
|
);
|
|
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
|
|
});
|
|
|
|
it('starts runtime when status check fails', () => {
|
|
// system status fails
|
|
mockExecSync.mockImplementationOnce(() => {
|
|
throw new Error('not running');
|
|
});
|
|
// system start succeeds
|
|
mockExecSync.mockReturnValueOnce('');
|
|
|
|
ensureContainerRuntimeRunning();
|
|
|
|
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
|
2,
|
|
`${CONTAINER_RUNTIME_BIN} system start`,
|
|
{ stdio: 'pipe', timeout: 30000 },
|
|
);
|
|
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
|
|
});
|
|
|
|
it('throws when both status and start fail', () => {
|
|
mockExecSync.mockImplementation(() => {
|
|
throw new Error('failed');
|
|
});
|
|
|
|
expect(() => ensureContainerRuntimeRunning()).toThrow(
|
|
'Container runtime is required but failed to start',
|
|
);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// --- cleanupOrphans ---
|
|
|
|
describe('cleanupOrphans', () => {
|
|
it('stops orphaned nanoclaw containers', () => {
|
|
const containers = [
|
|
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
|
|
{ status: 'running', configuration: { id: 'nanoclaw-group2-222' } },
|
|
{ status: 'running', configuration: { id: 'other-container' } },
|
|
{ status: 'stopped', configuration: { id: 'nanoclaw-old-333' } },
|
|
];
|
|
|
|
// ls returns container list
|
|
mockExecSync.mockReturnValueOnce(JSON.stringify(containers));
|
|
// stop calls succeed
|
|
mockExecSync.mockReturnValue('');
|
|
|
|
cleanupOrphans();
|
|
|
|
// ls + 2 stop calls (only running nanoclaw- containers)
|
|
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
|
2,
|
|
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
|
|
{ stdio: 'pipe' },
|
|
);
|
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
|
3,
|
|
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group2-222`,
|
|
{ stdio: 'pipe' },
|
|
);
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
|
|
'Stopped orphaned containers',
|
|
);
|
|
});
|
|
|
|
it('does nothing when no orphans exist', () => {
|
|
mockExecSync.mockReturnValueOnce(JSON.stringify([]));
|
|
|
|
cleanupOrphans();
|
|
|
|
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
expect(logger.info).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles empty output from ls', () => {
|
|
mockExecSync.mockReturnValueOnce('');
|
|
|
|
cleanupOrphans();
|
|
|
|
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warns and continues when ls fails', () => {
|
|
mockExecSync.mockImplementationOnce(() => {
|
|
throw new Error('runtime not available');
|
|
});
|
|
|
|
cleanupOrphans(); // should not throw
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ err: expect.any(Error) }),
|
|
'Failed to clean up orphaned containers',
|
|
);
|
|
});
|
|
|
|
it('continues stopping remaining containers when one stop fails', () => {
|
|
const containers = [
|
|
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
|
|
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
|
|
];
|
|
|
|
mockExecSync.mockReturnValueOnce(JSON.stringify(containers));
|
|
// First stop fails
|
|
mockExecSync.mockImplementationOnce(() => {
|
|
throw new Error('already stopped');
|
|
});
|
|
// Second stop succeeds
|
|
mockExecSync.mockReturnValueOnce('');
|
|
|
|
cleanupOrphans(); // should not throw
|
|
|
|
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
|
|
'Stopped orphaned containers',
|
|
);
|
|
});
|
|
});
|