Skip to content

Commit 955eeac

Browse files
committed
Improve type safety and test coverage for OpenCode integration
- Use `unknown` instead of `any` for dynamic SDK import - Add typed mock interfaces for Process and Sandbox in tests - Add tests for process reuse logic and API key extraction
1 parent 18a3a26 commit 955eeac

File tree

2 files changed

+185
-26
lines changed

2 files changed

+185
-26
lines changed

packages/sandbox/src/opencode/opencode.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { OpencodeStartupError } from './types';
1212
const DEFAULT_PORT = 4096;
1313

1414
// Dynamic import to handle peer dependency
15-
let createOpencodeClient: any;
15+
// Using unknown since SDK is optional peer dep - cast at usage site
16+
let createOpencodeClient: unknown;
1617

1718
async function ensureSdkLoaded(): Promise<void> {
1819
if (createOpencodeClient) return;
@@ -166,10 +167,16 @@ export async function createOpencode<TClient = unknown>(
166167
const process = await ensureOpencodeServer(sandbox, port, options?.config);
167168

168169
// Create SDK client with Sandbox transport
169-
const client = createOpencodeClient({
170+
// Cast from unknown - SDK is optional peer dependency loaded dynamically
171+
const clientFactory = createOpencodeClient as (options: {
172+
baseUrl: string;
173+
fetch: (request: Request) => Promise<Response>;
174+
}) => TClient;
175+
176+
const client = clientFactory({
170177
baseUrl: `http://localhost:${port}`,
171178
fetch: createSandboxFetch(sandbox, port)
172-
}) as TClient;
179+
});
173180

174181
// Build server handle
175182
const server: OpencodeServer = {
Lines changed: 175 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,72 @@
11
// packages/sandbox/tests/opencode/opencode.test.ts
2+
import type { Process, ProcessStatus } from '@repo/shared';
23
import { beforeEach, describe, expect, it, vi } from 'vitest';
34
import { createOpencode } from '../../src/opencode/opencode';
45
import { OpencodeStartupError } from '../../src/opencode/types';
6+
import type { Sandbox } from '../../src/sandbox';
57

68
// Mock the dynamic import of @opencode-ai/sdk
79
vi.mock('@opencode-ai/sdk', () => ({
810
createOpencodeClient: vi.fn().mockReturnValue({ session: {} })
911
}));
1012

13+
/** Minimal mock for Process methods used by OpenCode integration */
14+
interface MockProcess {
15+
id: string;
16+
command: string;
17+
status: ProcessStatus;
18+
startTime: Date;
19+
waitForPort: ReturnType<typeof vi.fn>;
20+
kill: ReturnType<typeof vi.fn>;
21+
getLogs: ReturnType<typeof vi.fn>;
22+
getStatus: ReturnType<typeof vi.fn>;
23+
waitForLog: ReturnType<typeof vi.fn>;
24+
}
25+
26+
/** Minimal mock for Sandbox methods used by OpenCode integration */
27+
interface MockSandbox {
28+
startProcess: ReturnType<typeof vi.fn>;
29+
listProcesses: ReturnType<typeof vi.fn>;
30+
containerFetch: ReturnType<typeof vi.fn>;
31+
}
32+
33+
function createMockProcess(overrides: Partial<MockProcess> = {}): MockProcess {
34+
return {
35+
id: 'proc-1',
36+
command: 'opencode serve --port 4096 --hostname 0.0.0.0',
37+
status: 'running',
38+
startTime: new Date(),
39+
waitForPort: vi.fn().mockResolvedValue(undefined),
40+
kill: vi.fn().mockResolvedValue(undefined),
41+
getLogs: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
42+
getStatus: vi.fn().mockResolvedValue('running'),
43+
waitForLog: vi.fn().mockResolvedValue({ line: '' }),
44+
...overrides
45+
};
46+
}
47+
48+
function createMockSandbox(overrides: Partial<MockSandbox> = {}): MockSandbox {
49+
return {
50+
startProcess: vi.fn(),
51+
listProcesses: vi.fn().mockResolvedValue([]),
52+
containerFetch: vi.fn().mockResolvedValue(new Response('ok')),
53+
...overrides
54+
};
55+
}
56+
1157
describe('createOpencode', () => {
12-
let mockSandbox: any;
13-
let mockProcess: any;
58+
let mockSandbox: MockSandbox;
59+
let mockProcess: MockProcess;
1460

1561
beforeEach(() => {
16-
mockProcess = {
17-
waitForPort: vi.fn().mockResolvedValue(undefined),
18-
kill: vi.fn().mockResolvedValue(undefined),
19-
getLogs: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
20-
};
21-
22-
mockSandbox = {
23-
startProcess: vi.fn().mockResolvedValue(mockProcess),
24-
containerFetch: vi.fn().mockResolvedValue(new Response('ok'))
25-
};
62+
mockProcess = createMockProcess();
63+
mockSandbox = createMockSandbox({
64+
startProcess: vi.fn().mockResolvedValue(mockProcess)
65+
});
2666
});
2767

2868
it('should start OpenCode server on default port 4096', async () => {
29-
const result = await createOpencode(mockSandbox);
69+
const result = await createOpencode(mockSandbox as unknown as Sandbox);
3070

3171
expect(mockSandbox.startProcess).toHaveBeenCalledWith(
3272
'opencode serve --port 4096 --hostname 0.0.0.0',
@@ -37,7 +77,9 @@ describe('createOpencode', () => {
3777
});
3878

3979
it('should start OpenCode server on custom port', async () => {
40-
const result = await createOpencode(mockSandbox, { port: 8080 });
80+
const result = await createOpencode(mockSandbox as unknown as Sandbox, {
81+
port: 8080
82+
});
4183

4284
expect(mockSandbox.startProcess).toHaveBeenCalledWith(
4385
'opencode serve --port 8080 --hostname 0.0.0.0',
@@ -48,18 +90,41 @@ describe('createOpencode', () => {
4890

4991
it('should pass config via OPENCODE_CONFIG_CONTENT env var', async () => {
5092
const config = { provider: { anthropic: { apiKey: 'test-key' } } };
51-
await createOpencode(mockSandbox, { config });
93+
await createOpencode(mockSandbox as unknown as Sandbox, { config });
5294

5395
expect(mockSandbox.startProcess).toHaveBeenCalledWith(
5496
expect.any(String),
5597
expect.objectContaining({
56-
env: { OPENCODE_CONFIG_CONTENT: JSON.stringify(config) }
98+
env: expect.objectContaining({
99+
OPENCODE_CONFIG_CONTENT: JSON.stringify(config)
100+
})
101+
})
102+
);
103+
});
104+
105+
it('should extract API keys from config to env vars', async () => {
106+
const config = {
107+
provider: {
108+
anthropic: { apiKey: 'anthropic-key' },
109+
openai: { apiKey: 'openai-key' }
110+
}
111+
};
112+
await createOpencode(mockSandbox as unknown as Sandbox, { config });
113+
114+
expect(mockSandbox.startProcess).toHaveBeenCalledWith(
115+
expect.any(String),
116+
expect.objectContaining({
117+
env: expect.objectContaining({
118+
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
119+
ANTHROPIC_API_KEY: 'anthropic-key',
120+
OPENAI_API_KEY: 'openai-key'
121+
})
57122
})
58123
);
59124
});
60125

61126
it('should wait for port to be ready', async () => {
62-
await createOpencode(mockSandbox);
127+
await createOpencode(mockSandbox as unknown as Sandbox);
63128

64129
expect(mockProcess.waitForPort).toHaveBeenCalledWith(4096, {
65130
mode: 'http',
@@ -69,15 +134,15 @@ describe('createOpencode', () => {
69134
});
70135

71136
it('should return client and server', async () => {
72-
const result = await createOpencode(mockSandbox);
137+
const result = await createOpencode(mockSandbox as unknown as Sandbox);
73138

74139
expect(result.client).toBeDefined();
75140
expect(result.server).toBeDefined();
76141
expect(result.server.process).toBe(mockProcess);
77142
});
78143

79144
it('should provide stop method that kills process', async () => {
80-
const result = await createOpencode(mockSandbox);
145+
const result = await createOpencode(mockSandbox as unknown as Sandbox);
81146

82147
await result.server.stop();
83148

@@ -91,9 +156,96 @@ describe('createOpencode', () => {
91156
stderr: 'Server crashed'
92157
});
93158

94-
await expect(createOpencode(mockSandbox)).rejects.toThrow(
95-
OpencodeStartupError
96-
);
97-
await expect(createOpencode(mockSandbox)).rejects.toThrow(/Server crashed/);
159+
await expect(
160+
createOpencode(mockSandbox as unknown as Sandbox)
161+
).rejects.toThrow(OpencodeStartupError);
162+
await expect(
163+
createOpencode(mockSandbox as unknown as Sandbox)
164+
).rejects.toThrow(/Server crashed/);
165+
});
166+
167+
describe('process reuse', () => {
168+
it('should reuse existing running process on same port', async () => {
169+
const existingProcess = createMockProcess({
170+
command: 'opencode serve --port 4096 --hostname 0.0.0.0',
171+
status: 'running'
172+
});
173+
mockSandbox.listProcesses.mockResolvedValue([existingProcess]);
174+
175+
const result = await createOpencode(mockSandbox as unknown as Sandbox);
176+
177+
// Should not start a new process
178+
expect(mockSandbox.startProcess).not.toHaveBeenCalled();
179+
// Should return the existing process
180+
expect(result.server.process).toBe(existingProcess);
181+
});
182+
183+
it('should wait for starting process to be ready', async () => {
184+
const startingProcess = createMockProcess({
185+
command: 'opencode serve --port 4096 --hostname 0.0.0.0',
186+
status: 'starting'
187+
});
188+
mockSandbox.listProcesses.mockResolvedValue([startingProcess]);
189+
190+
await createOpencode(mockSandbox as unknown as Sandbox);
191+
192+
// Should not start a new process
193+
expect(mockSandbox.startProcess).not.toHaveBeenCalled();
194+
// Should wait for the existing process
195+
expect(startingProcess.waitForPort).toHaveBeenCalledWith(4096, {
196+
mode: 'http',
197+
path: '/',
198+
timeout: 60_000
199+
});
200+
});
201+
202+
it('should start new process when existing one has completed', async () => {
203+
const completedProcess = createMockProcess({
204+
command: 'opencode serve --port 4096 --hostname 0.0.0.0',
205+
status: 'completed'
206+
});
207+
mockSandbox.listProcesses.mockResolvedValue([completedProcess]);
208+
209+
await createOpencode(mockSandbox as unknown as Sandbox);
210+
211+
// Should start a new process since existing one completed
212+
expect(mockSandbox.startProcess).toHaveBeenCalled();
213+
});
214+
215+
it('should start new process on different port', async () => {
216+
const existingProcess = createMockProcess({
217+
command: 'opencode serve --port 4096 --hostname 0.0.0.0',
218+
status: 'running'
219+
});
220+
mockSandbox.listProcesses.mockResolvedValue([existingProcess]);
221+
222+
await createOpencode(mockSandbox as unknown as Sandbox, { port: 8080 });
223+
224+
// Should start new process on different port
225+
expect(mockSandbox.startProcess).toHaveBeenCalledWith(
226+
'opencode serve --port 8080 --hostname 0.0.0.0',
227+
expect.any(Object)
228+
);
229+
});
230+
231+
it('should throw OpencodeStartupError when starting process fails to become ready', async () => {
232+
const startingProcess = createMockProcess({
233+
command: 'opencode serve --port 4096 --hostname 0.0.0.0',
234+
status: 'starting'
235+
});
236+
startingProcess.waitForPort.mockRejectedValue(new Error('timeout'));
237+
startingProcess.getLogs.mockResolvedValue({
238+
stdout: '',
239+
stderr: 'Startup failed'
240+
});
241+
mockSandbox.listProcesses.mockResolvedValue([startingProcess]);
242+
243+
await expect(
244+
createOpencode(mockSandbox as unknown as Sandbox)
245+
).rejects.toThrow(OpencodeStartupError);
246+
await expect(
247+
createOpencode(mockSandbox as unknown as Sandbox)
248+
).rejects.toThrow(/Startup failed/);
249+
});
98250
});
99251
});

0 commit comments

Comments
 (0)