Skip to content

Commit 8dac9cb

Browse files
notgitikatejaskash
authored andcommitted
test: add unit tests for container agent support
1 parent 2a6e64e commit 8dac9cb

9 files changed

Lines changed: 1446 additions & 2 deletions

File tree

src/cli/external-requirements/__tests__/checks-extended.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { AgentCoreProjectSpec, DirectoryPath, FilePath } from '../../../schema';
2-
import { checkDependencyVersions, checkNodeVersion, formatVersionError, requiresUv } from '../checks.js';
2+
import {
3+
checkDependencyVersions,
4+
checkNodeVersion,
5+
formatVersionError,
6+
requiresContainerRuntime,
7+
requiresUv,
8+
} from '../checks.js';
39
import { describe, expect, it } from 'vitest';
410

511
describe('formatVersionError', () => {
@@ -76,6 +82,87 @@ describe('requiresUv', () => {
7682
});
7783
});
7884

85+
describe('requiresContainerRuntime', () => {
86+
it('returns true when project has Container agents', () => {
87+
const project: AgentCoreProjectSpec = {
88+
name: 'Test',
89+
version: 1,
90+
agents: [
91+
{
92+
type: 'AgentCoreRuntime',
93+
name: 'Agent1',
94+
build: 'Container',
95+
runtimeVersion: 'PYTHON_3_12',
96+
entrypoint: 'main.py' as FilePath,
97+
codeLocation: './app' as DirectoryPath,
98+
},
99+
],
100+
memories: [],
101+
credentials: [],
102+
};
103+
expect(requiresContainerRuntime(project)).toBe(true);
104+
});
105+
106+
it('returns false when project only has CodeZip agents', () => {
107+
const project: AgentCoreProjectSpec = {
108+
name: 'Test',
109+
version: 1,
110+
agents: [
111+
{
112+
type: 'AgentCoreRuntime',
113+
name: 'Agent1',
114+
build: 'CodeZip',
115+
runtimeVersion: 'PYTHON_3_12',
116+
entrypoint: 'main.py' as FilePath,
117+
codeLocation: './app' as DirectoryPath,
118+
},
119+
],
120+
memories: [],
121+
credentials: [],
122+
};
123+
expect(requiresContainerRuntime(project)).toBe(false);
124+
});
125+
126+
it('returns false for empty agents array', () => {
127+
const project: AgentCoreProjectSpec = {
128+
name: 'Test',
129+
version: 1,
130+
agents: [],
131+
memories: [],
132+
credentials: [],
133+
};
134+
expect(requiresContainerRuntime(project)).toBe(false);
135+
});
136+
137+
it('returns true with mixed Container and CodeZip agents', () => {
138+
const project: AgentCoreProjectSpec = {
139+
name: 'Test',
140+
version: 1,
141+
agents: [
142+
{
143+
type: 'AgentCoreRuntime',
144+
name: 'Agent1',
145+
build: 'CodeZip',
146+
runtimeVersion: 'PYTHON_3_12',
147+
entrypoint: 'main.py' as FilePath,
148+
codeLocation: './app' as DirectoryPath,
149+
},
150+
{
151+
type: 'AgentCoreRuntime',
152+
name: 'Agent2',
153+
build: 'Container',
154+
runtimeVersion: 'PYTHON_3_12',
155+
entrypoint: 'app.py' as FilePath,
156+
codeLocation: './container-app' as DirectoryPath,
157+
},
158+
],
159+
memories: [],
160+
credentials: [],
161+
};
162+
expect(requiresContainerRuntime(project)).toBe(true);
163+
});
164+
});
165+
79166
describe('checkNodeVersion', () => {
80167
it('returns a version check result', async () => {
81168
const result = await checkNodeVersion();
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { detectContainerRuntime, getStartHint, requireContainerRuntime } from '../detect.js';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { mockCheckSubprocess, mockRunSubprocessCapture } = vi.hoisted(() => ({
5+
mockCheckSubprocess: vi.fn(),
6+
mockRunSubprocessCapture: vi.fn(),
7+
}));
8+
9+
vi.mock('../../../lib', () => ({
10+
CONTAINER_RUNTIMES: ['docker', 'podman', 'finch'],
11+
START_HINTS: {
12+
docker: 'Start Docker Desktop or run: sudo systemctl start docker',
13+
podman: 'Run: podman machine start',
14+
finch: 'Run: finch vm init && finch vm start',
15+
},
16+
checkSubprocess: mockCheckSubprocess,
17+
runSubprocessCapture: mockRunSubprocessCapture,
18+
isWindows: false,
19+
}));
20+
21+
afterEach(() => vi.clearAllMocks());
22+
23+
describe('getStartHint', () => {
24+
it('formats a single runtime hint', () => {
25+
const result = getStartHint(['docker']);
26+
expect(result).toBe(' docker: Start Docker Desktop or run: sudo systemctl start docker');
27+
});
28+
29+
it('joins multiple runtime hints with newlines', () => {
30+
const result = getStartHint(['docker', 'finch']);
31+
expect(result).toBe(
32+
' docker: Start Docker Desktop or run: sudo systemctl start docker\n' +
33+
' finch: Run: finch vm init && finch vm start'
34+
);
35+
});
36+
37+
it('returns empty string for empty array', () => {
38+
const result = getStartHint([]);
39+
expect(result).toBe('');
40+
});
41+
});
42+
43+
describe('detectContainerRuntime', () => {
44+
it('returns docker when docker is installed and ready', async () => {
45+
mockCheckSubprocess.mockResolvedValue(true);
46+
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
47+
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' });
48+
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
49+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
50+
});
51+
52+
const result = await detectContainerRuntime();
53+
expect(result.runtime).toEqual({ runtime: 'docker', binary: 'docker', version: 'Docker version 24.0.0' });
54+
expect(result.notReadyRuntimes).toEqual([]);
55+
});
56+
57+
it('falls back to podman when docker not installed', async () => {
58+
mockCheckSubprocess.mockImplementation((_cmd: string, args: string[]) => {
59+
if (args[0] === 'docker') return Promise.resolve(false);
60+
if (args[0] === 'podman') return Promise.resolve(true);
61+
return Promise.resolve(false);
62+
});
63+
mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => {
64+
if (bin === 'podman' && args[0] === '--version')
65+
return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' });
66+
if (bin === 'podman' && args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
67+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
68+
});
69+
70+
const result = await detectContainerRuntime();
71+
expect(result.runtime).toEqual({ runtime: 'podman', binary: 'podman', version: 'podman version 4.5.0' });
72+
});
73+
74+
it('reports docker as notReady when installed but daemon not running', async () => {
75+
// docker exists and --version works, but info fails
76+
mockCheckSubprocess.mockImplementation((_cmd: string, args: string[]) => {
77+
if (args[0] === 'docker') return Promise.resolve(true);
78+
return Promise.resolve(false);
79+
});
80+
mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => {
81+
if (bin === 'docker' && args[0] === '--version')
82+
return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' });
83+
if (bin === 'docker' && args[0] === 'info')
84+
return Promise.resolve({ code: 1, stdout: '', stderr: 'Cannot connect to the Docker daemon' });
85+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
86+
});
87+
88+
const result = await detectContainerRuntime();
89+
expect(result.runtime).toBeNull();
90+
expect(result.notReadyRuntimes).toContain('docker');
91+
});
92+
93+
it('returns null runtime when nothing is installed', async () => {
94+
mockCheckSubprocess.mockResolvedValue(false);
95+
96+
const result = await detectContainerRuntime();
97+
expect(result.runtime).toBeNull();
98+
expect(result.notReadyRuntimes).toEqual([]);
99+
});
100+
101+
it('returns null with notReadyRuntimes when installed but not ready', async () => {
102+
mockCheckSubprocess.mockResolvedValue(true);
103+
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
104+
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'v1.0.0\n', stderr: '' });
105+
if (args[0] === 'info') return Promise.resolve({ code: 1, stdout: '', stderr: 'not running' });
106+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
107+
});
108+
109+
const result = await detectContainerRuntime();
110+
expect(result.runtime).toBeNull();
111+
expect(result.notReadyRuntimes).toEqual(['docker', 'podman', 'finch']);
112+
});
113+
114+
it('skips runtime when --version check fails', async () => {
115+
mockCheckSubprocess.mockResolvedValue(true);
116+
mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => {
117+
// docker --version fails, podman works
118+
if (bin === 'docker' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' });
119+
if (bin === 'podman' && args[0] === '--version')
120+
return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' });
121+
if (bin === 'podman' && args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
122+
// finch --version also fails
123+
if (bin === 'finch' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' });
124+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
125+
});
126+
127+
const result = await detectContainerRuntime();
128+
expect(result.runtime).toEqual({ runtime: 'podman', binary: 'podman', version: 'podman version 4.5.0' });
129+
expect(result.notReadyRuntimes).toEqual([]);
130+
});
131+
132+
it('extracts first line of --version output as version string', async () => {
133+
mockCheckSubprocess.mockResolvedValue(true);
134+
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
135+
if (args[0] === '--version')
136+
return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\nExtra info line\n', stderr: '' });
137+
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
138+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
139+
});
140+
141+
const result = await detectContainerRuntime();
142+
expect(result.runtime?.version).toBe('Docker version 24.0.0');
143+
});
144+
145+
it('uses empty first line when version output is empty', async () => {
146+
mockCheckSubprocess.mockResolvedValue(true);
147+
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
148+
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
149+
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
150+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
151+
});
152+
153+
const result = await detectContainerRuntime();
154+
// ''.trim().split('\n')[0] returns '' (not undefined), so ?? 'unknown' doesn't trigger
155+
expect(result.runtime?.version).toBe('');
156+
});
157+
});
158+
159+
describe('requireContainerRuntime', () => {
160+
it('returns runtime info when available', async () => {
161+
mockCheckSubprocess.mockResolvedValue(true);
162+
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
163+
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' });
164+
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
165+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
166+
});
167+
168+
const result = await requireContainerRuntime();
169+
expect(result).toEqual({ runtime: 'docker', binary: 'docker', version: 'Docker version 24.0.0' });
170+
});
171+
172+
it('throws with install links when no runtime found and none notReady', async () => {
173+
mockCheckSubprocess.mockResolvedValue(false);
174+
175+
await expect(requireContainerRuntime()).rejects.toThrow('No container runtime found');
176+
await expect(requireContainerRuntime()).rejects.toThrow('https://docker.com');
177+
});
178+
179+
it('throws with start hints when runtimes installed but not ready', async () => {
180+
mockCheckSubprocess.mockResolvedValue(true);
181+
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
182+
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'v1.0.0\n', stderr: '' });
183+
if (args[0] === 'info') return Promise.resolve({ code: 1, stdout: '', stderr: 'not running' });
184+
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
185+
});
186+
187+
await expect(requireContainerRuntime()).rejects.toThrow('not ready');
188+
await expect(requireContainerRuntime()).rejects.toThrow('Start a runtime');
189+
});
190+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { AgentCoreProjectSpec, DirectoryPath } from '../../../../schema';
2+
import { validateContainerAgents } from '../preflight.js';
3+
import { existsSync } from 'node:fs';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
vi.mock('node:fs', () => ({
7+
existsSync: vi.fn(),
8+
}));
9+
10+
vi.mock('../../../../lib', () => ({
11+
DOCKERFILE_NAME: 'Dockerfile',
12+
resolveCodeLocation: vi.fn((codeLocation: string, configBaseDir: string) => {
13+
// eslint-disable-next-line @typescript-eslint/no-require-imports
14+
const p = require('node:path') as typeof import('node:path');
15+
const repoRoot = p.dirname(configBaseDir);
16+
return p.resolve(repoRoot, codeLocation);
17+
}),
18+
// Stub other exports that the module may pull in
19+
ConfigIO: vi.fn(),
20+
requireConfigRoot: vi.fn(),
21+
}));
22+
23+
const mockedExistsSync = vi.mocked(existsSync);
24+
25+
const CONFIG_ROOT = '/project/agentcore';
26+
27+
/** Helper to cast plain strings to the branded DirectoryPath type used by the schema. */
28+
const dir = (s: string) => s as DirectoryPath;
29+
30+
function makeSpec(agents: Record<string, unknown>[]): AgentCoreProjectSpec {
31+
return {
32+
name: 'test-project',
33+
agents,
34+
} as unknown as AgentCoreProjectSpec;
35+
}
36+
37+
describe('validateContainerAgents', () => {
38+
afterEach(() => {
39+
vi.clearAllMocks();
40+
});
41+
42+
it('does nothing when there are no Container agents', () => {
43+
const spec = makeSpec([{ name: 'zip-agent', build: 'CodeZip', codeLocation: dir('agents/zip-agent') }]);
44+
45+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow();
46+
expect(mockedExistsSync).not.toHaveBeenCalled();
47+
});
48+
49+
it('does nothing when Container agent has a valid Dockerfile', () => {
50+
mockedExistsSync.mockReturnValue(true);
51+
52+
const spec = makeSpec([
53+
{ name: 'container-agent', build: 'Container', codeLocation: dir('agents/container-agent') },
54+
]);
55+
56+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow();
57+
expect(mockedExistsSync).toHaveBeenCalledTimes(1);
58+
});
59+
60+
it('throws when Container agent is missing a Dockerfile', () => {
61+
mockedExistsSync.mockReturnValue(false);
62+
63+
const spec = makeSpec([{ name: 'my-container', build: 'Container', codeLocation: dir('agents/my-container') }]);
64+
65+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/Dockerfile not found/);
66+
});
67+
68+
it('only validates Container agents and skips CodeZip agents', () => {
69+
mockedExistsSync.mockReturnValue(true);
70+
71+
const spec = makeSpec([
72+
{ name: 'zip-agent', build: 'CodeZip', codeLocation: dir('agents/zip-agent') },
73+
{ name: 'container-agent', build: 'Container', codeLocation: dir('agents/container-agent') },
74+
]);
75+
76+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow();
77+
// Only the Container agent should trigger an existsSync check
78+
expect(mockedExistsSync).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it('includes the agent name in the error message', () => {
82+
mockedExistsSync.mockReturnValue(false);
83+
84+
const spec = makeSpec([{ name: 'bad-agent', build: 'Container', codeLocation: dir('agents/bad-agent') }]);
85+
86+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/bad-agent/);
87+
});
88+
89+
it('reports errors for all failing Container agents', () => {
90+
mockedExistsSync.mockReturnValue(false);
91+
92+
const spec = makeSpec([
93+
{ name: 'agent-a', build: 'Container', codeLocation: dir('agents/a') },
94+
{ name: 'agent-b', build: 'Container', codeLocation: dir('agents/b') },
95+
]);
96+
97+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/agent-a.*agent-b/s);
98+
});
99+
});

0 commit comments

Comments
 (0)