@@ -8,15 +8,19 @@ declare module 'vitest' {
88 }
99}
1010
11+ type OutputBuffer = Array < { channel : 'stdout' | 'stderr' ; data : Buffer } >
12+
1113export default async function setup ( project : TestProject ) {
1214 const mcpServerPort = await getPort ( )
1315
1416 project . provide ( 'mcpServerPort' , mcpServerPort )
1517
18+ let appServerProcess : ReturnType < typeof execa > | null = null
1619 let mcpServerProcess : ReturnType < typeof execa > | null = null
1720
1821 // Buffers to store output for potential error display
19- const mcpServerOutput : Array < string > = [ ]
22+ const appServerOutput : OutputBuffer = [ ]
23+ const mcpServerOutput : OutputBuffer = [ ]
2024
2125 /**
2226 * Wait for a server to be ready by monitoring its output for a specific text pattern
@@ -30,7 +34,7 @@ export default async function setup(project: TestProject) {
3034 process : ReturnType < typeof execa > | null
3135 textMatch : string
3236 name : string
33- outputBuffer : Array < string >
37+ outputBuffer : OutputBuffer
3438 } ) {
3539 if ( ! childProcess ) return
3640
@@ -40,9 +44,12 @@ export default async function setup(project: TestProject) {
4044 reject ( new Error ( `${ name } failed to start within 10 seconds` ) )
4145 } , 10_000 )
4246
43- function searchForMatch ( data : Buffer ) {
47+ function searchForMatch (
48+ channel : OutputBuffer [ number ] [ 'channel' ] ,
49+ data : Buffer ,
50+ ) {
51+ outputBuffer . push ( { channel, data } )
4452 const str = data . toString ( )
45- outputBuffer . push ( str )
4653 if ( str . includes ( textMatch ) ) {
4754 clearTimeout ( timeout )
4855 // Remove the listeners after finding the match
@@ -51,8 +58,9 @@ export default async function setup(project: TestProject) {
5158 resolve ( )
5259 }
5360 }
54- childProcess ?. stdout ?. on ( 'data' , searchForMatch )
55- childProcess ?. stderr ?. on ( 'data' , searchForMatch )
61+
62+ childProcess ?. stdout ?. on ( 'data' , searchForMatch . bind ( null , 'stdout' ) )
63+ childProcess ?. stderr ?. on ( 'data' , searchForMatch . bind ( null , 'stderr' ) )
5664
5765 childProcess ?. on ( 'error' , ( err ) => {
5866 clearTimeout ( timeout )
@@ -72,17 +80,54 @@ export default async function setup(project: TestProject) {
7280 * Display buffered output when there's a failure
7381 */
7482 function displayBufferedOutput ( ) {
83+ if ( appServerOutput . length > 0 ) {
84+ console . log ( '=== App Server Output ===' )
85+ for ( const { channel, data } of appServerOutput ) {
86+ process [ channel ] . write ( data )
87+ }
88+ }
7589 if ( mcpServerOutput . length > 0 ) {
7690 console . log ( '=== MCP Server Output ===' )
77- for ( const line of mcpServerOutput ) {
78- process . stdout . write ( line )
91+ for ( const { channel , data } of mcpServerOutput ) {
92+ process [ channel ] . write ( data )
7993 }
8094 }
8195 }
8296
97+ async function startAppServerIfNecessary ( ) {
98+ const isAppRunning = await fetch ( 'http://localhost:7787/healthcheck' ) . catch (
99+ ( ) => ( { ok : false } ) ,
100+ )
101+ if ( isAppRunning . ok ) {
102+ return
103+ }
104+
105+ const rootDir = process . cwd ( ) . replace ( / e x e r c i s e s \/ .* $ / , '' )
106+
107+ // Start the app server from the root directory
108+ console . log ( `Starting app server on port 7787...` )
109+ const command = 'npm'
110+ // prettier-ignore
111+ const args = [
112+ 'run' , 'dev' ,
113+ '--prefix' , './epicshop/epic-me' ,
114+ '--' ,
115+ '--clearScreen=false' ,
116+ '--strictPort' ,
117+ ]
118+
119+ appServerProcess = execa ( command , args , {
120+ cwd : rootDir ,
121+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
122+ } )
123+ }
124+
83125 async function startServers ( ) {
84126 console . log ( 'Starting servers...' )
85127
128+ // Start app server if necessary
129+ await startAppServerIfNecessary ( )
130+
86131 // Start the MCP server from the exercise directory
87132 console . log ( `Starting MCP server on port ${ mcpServerPort } ...` )
88133 mcpServerProcess = execa (
@@ -99,13 +144,25 @@ export default async function setup(project: TestProject) {
99144 )
100145
101146 try {
102- // Wait for MCP server to be ready
103- await waitForServerReady ( {
104- process : mcpServerProcess ,
105- textMatch : mcpServerPort . toString ( ) ,
106- name : '[MCP-SERVER]' ,
107- outputBuffer : mcpServerOutput ,
108- } )
147+ // Wait for both servers to be ready simultaneously
148+ await Promise . all ( [
149+ appServerProcess
150+ ? waitForServerReady ( {
151+ process : appServerProcess ,
152+ textMatch : '7787' ,
153+ name : '[APP-SERVER]' ,
154+ outputBuffer : appServerOutput ,
155+ } ) . then ( ( ) =>
156+ waitForResourceReady ( 'http://localhost:7787/healthcheck' ) ,
157+ )
158+ : Promise . resolve ( ) ,
159+ waitForServerReady ( {
160+ process : mcpServerProcess ,
161+ textMatch : mcpServerPort . toString ( ) ,
162+ name : '[MCP-SERVER]' ,
163+ outputBuffer : mcpServerOutput ,
164+ } ) ,
165+ ] )
109166
110167 console . log ( 'Servers started successfully' )
111168 } catch ( error ) {
@@ -124,12 +181,12 @@ export default async function setup(project: TestProject) {
124181 cleanupPromises . push (
125182 ( async ( ) => {
126183 mcpServerProcess . kill ( 'SIGTERM' )
127- // Give it 2 seconds to gracefully shutdown, then force kill
184+ // Give it time to gracefully shutdown, then force kill
128185 const timeout = setTimeout ( ( ) => {
129186 if ( mcpServerProcess && ! mcpServerProcess . killed ) {
130187 mcpServerProcess . kill ( 'SIGKILL' )
131188 }
132- } , 2000 )
189+ } , 500 )
133190
134191 try {
135192 await mcpServerProcess
@@ -142,6 +199,28 @@ export default async function setup(project: TestProject) {
142199 )
143200 }
144201
202+ if ( appServerProcess && ! appServerProcess . killed ) {
203+ cleanupPromises . push (
204+ ( async ( ) => {
205+ appServerProcess . kill ( 'SIGTERM' )
206+ // Give time to gracefully shutdown, then force kill
207+ const timeout = setTimeout ( ( ) => {
208+ if ( appServerProcess && ! appServerProcess . killed ) {
209+ appServerProcess . kill ( 'SIGKILL' )
210+ }
211+ } , 500 )
212+
213+ try {
214+ await appServerProcess
215+ } catch {
216+ // Process was killed, which is expected
217+ } finally {
218+ clearTimeout ( timeout )
219+ }
220+ } ) ( ) ,
221+ )
222+ }
223+
145224 // Wait for all cleanup to complete, but with an overall timeout
146225 try {
147226 await Promise . race ( [
@@ -159,6 +238,9 @@ export default async function setup(project: TestProject) {
159238 if ( mcpServerProcess && ! mcpServerProcess . killed ) {
160239 mcpServerProcess . kill ( 'SIGKILL' )
161240 }
241+ if ( appServerProcess && ! appServerProcess . killed ) {
242+ appServerProcess . kill ( 'SIGKILL' )
243+ }
162244 }
163245
164246 console . log ( 'Servers cleaned up' )
@@ -170,3 +252,41 @@ export default async function setup(project: TestProject) {
170252 // Return cleanup function
171253 return cleanup
172254}
255+
256+ function waitForResourceReady ( resourceUrl : string ) {
257+ const timeoutSignal = AbortSignal . timeout ( 10_000 )
258+ let lastError : Error | null = null
259+ return new Promise < void > ( ( resolve , reject ) => {
260+ timeoutSignal . addEventListener ( 'abort' , ( ) => {
261+ const error = lastError ?? new Error ( 'No other errors detected' )
262+ error . message = `Timed out waiting for ${ resourceUrl } :\n Last Error:${ error . message } `
263+ reject ( error )
264+ } )
265+ async function checkResource ( ) {
266+ try {
267+ const response = await fetch ( resourceUrl )
268+ if ( response . ok ) return resolve ( )
269+ } catch ( error ) {
270+ lastError = error instanceof Error ? error : new Error ( String ( error ) )
271+ }
272+ await sleep ( 100 , timeoutSignal )
273+ await checkResource ( )
274+ }
275+ return checkResource ( )
276+ } )
277+ }
278+
279+ function sleep ( ms : number , signal ?: AbortSignal ) {
280+ return new Promise < void > ( ( resolve , reject ) => {
281+ const timeout = setTimeout ( ( ) => {
282+ signal ?. removeEventListener ( 'abort' , onAbort )
283+ resolve ( )
284+ } , ms )
285+
286+ function onAbort ( ) {
287+ clearTimeout ( timeout )
288+ reject ( new Error ( 'Sleep aborted' ) )
289+ }
290+ signal ?. addEventListener ( 'abort' , onAbort )
291+ } )
292+ }
0 commit comments