11// packages/sandbox/tests/opencode/opencode.test.ts
2+ import type { Process , ProcessStatus } from '@repo/shared' ;
23import { beforeEach , describe , expect , it , vi } from 'vitest' ;
34import { createOpencode } from '../../src/opencode/opencode' ;
45import { OpencodeStartupError } from '../../src/opencode/types' ;
6+ import type { Sandbox } from '../../src/sandbox' ;
57
68// Mock the dynamic import of @opencode -ai/sdk
79vi . 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+
1157describe ( '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 ( / S e r v e r c r a s h e d / ) ;
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 ( / S e r v e r c r a s h e d / ) ;
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 ( / S t a r t u p f a i l e d / ) ;
249+ } ) ;
98250 } ) ;
99251} ) ;
0 commit comments