Skip to content

Commit e944575

Browse files
committed
make global setup more reliable
1 parent adffc5f commit e944575

File tree

26 files changed

+1976
-832
lines changed

26 files changed

+1976
-832
lines changed

exercises/01.discovery/01.problem.cors/test/globalSetup.ts

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ declare module 'vitest' {
88
}
99
}
1010

11+
type OutputBuffer = Array<{ channel: 'stdout' | 'stderr'; data: Buffer }>
12+
1113
export default async function setup(project: TestProject) {
1214
const mcpServerPort = await getPort()
1315

@@ -17,8 +19,8 @@ export default async function setup(project: TestProject) {
1719
let mcpServerProcess: ReturnType<typeof execa> | null = null
1820

1921
// Buffers to store output for potential error display
20-
const appServerOutput: Array<string> = []
21-
const mcpServerOutput: Array<string> = []
22+
const appServerOutput: OutputBuffer = []
23+
const mcpServerOutput: OutputBuffer = []
2224

2325
/**
2426
* Wait for a server to be ready by monitoring its output for a specific text pattern
@@ -32,7 +34,7 @@ export default async function setup(project: TestProject) {
3234
process: ReturnType<typeof execa> | null
3335
textMatch: string
3436
name: string
35-
outputBuffer: Array<string>
37+
outputBuffer: OutputBuffer
3638
}) {
3739
if (!childProcess) return
3840

@@ -42,9 +44,12 @@ export default async function setup(project: TestProject) {
4244
reject(new Error(`${name} failed to start within 10 seconds`))
4345
}, 10_000)
4446

45-
function searchForMatch(data: Buffer) {
47+
function searchForMatch(
48+
channel: OutputBuffer[number]['channel'],
49+
data: Buffer,
50+
) {
51+
outputBuffer.push({ channel, data })
4652
const str = data.toString()
47-
outputBuffer.push(str)
4853
if (str.includes(textMatch)) {
4954
clearTimeout(timeout)
5055
// Remove the listeners after finding the match
@@ -53,8 +58,9 @@ export default async function setup(project: TestProject) {
5358
resolve()
5459
}
5560
}
56-
childProcess?.stdout?.on('data', searchForMatch)
57-
childProcess?.stderr?.on('data', searchForMatch)
61+
62+
childProcess?.stdout?.on('data', searchForMatch.bind(null, 'stdout'))
63+
childProcess?.stderr?.on('data', searchForMatch.bind(null, 'stderr'))
5864

5965
childProcess?.on('error', (err) => {
6066
clearTimeout(timeout)
@@ -76,14 +82,14 @@ export default async function setup(project: TestProject) {
7682
function displayBufferedOutput() {
7783
if (appServerOutput.length > 0) {
7884
console.log('=== App Server Output ===')
79-
for (const line of appServerOutput) {
80-
process.stdout.write(line)
85+
for (const { channel, data } of appServerOutput) {
86+
process[channel].write(data)
8187
}
8288
}
8389
if (mcpServerOutput.length > 0) {
8490
console.log('=== MCP Server Output ===')
85-
for (const line of mcpServerOutput) {
86-
process.stdout.write(line)
91+
for (const { channel, data } of mcpServerOutput) {
92+
process[channel].write(data)
8793
}
8894
}
8995
}
@@ -100,22 +106,20 @@ export default async function setup(project: TestProject) {
100106

101107
// Start the app server from the root directory
102108
console.log(`Starting app server on port 7788...`)
103-
appServerProcess = execa(
104-
'npm',
105-
[
106-
'run',
107-
'dev',
108-
'--prefix',
109-
'./epicshop/epic-me',
110-
'--',
111-
'--clearScreen=false',
112-
'--strictPort',
113-
],
114-
{
115-
cwd: rootDir,
116-
stdio: ['ignore', 'pipe', 'pipe'],
117-
},
118-
)
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+
})
119123
}
120124

121125
async function startServers() {
@@ -148,7 +152,9 @@ export default async function setup(project: TestProject) {
148152
textMatch: '7788',
149153
name: '[APP-SERVER]',
150154
outputBuffer: appServerOutput,
151-
})
155+
}).then(() =>
156+
waitForResourceReady('http://localhost:7788/healthcheck'),
157+
)
152158
: Promise.resolve(),
153159
waitForServerReady({
154160
process: mcpServerProcess,
@@ -175,12 +181,12 @@ export default async function setup(project: TestProject) {
175181
cleanupPromises.push(
176182
(async () => {
177183
mcpServerProcess.kill('SIGTERM')
178-
// Give it 2 seconds to gracefully shutdown, then force kill
184+
// Give it time to gracefully shutdown, then force kill
179185
const timeout = setTimeout(() => {
180186
if (mcpServerProcess && !mcpServerProcess.killed) {
181187
mcpServerProcess.kill('SIGKILL')
182188
}
183-
}, 2000)
189+
}, 500)
184190

185191
try {
186192
await mcpServerProcess
@@ -197,12 +203,12 @@ export default async function setup(project: TestProject) {
197203
cleanupPromises.push(
198204
(async () => {
199205
appServerProcess.kill('SIGTERM')
200-
// Give it 2 seconds to gracefully shutdown, then force kill
206+
// Give time to gracefully shutdown, then force kill
201207
const timeout = setTimeout(() => {
202208
if (appServerProcess && !appServerProcess.killed) {
203209
appServerProcess.kill('SIGKILL')
204210
}
205-
}, 2000)
211+
}, 500)
206212

207213
try {
208214
await appServerProcess
@@ -246,3 +252,41 @@ export default async function setup(project: TestProject) {
246252
// Return cleanup function
247253
return cleanup
248254
}
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+
}

exercises/01.discovery/01.solution.cors/test/globalSetup.ts

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ declare module 'vitest' {
88
}
99
}
1010

11+
type OutputBuffer = Array<{ channel: 'stdout' | 'stderr'; data: Buffer }>
12+
1113
export default async function setup(project: TestProject) {
1214
const mcpServerPort = await getPort()
1315

@@ -17,8 +19,8 @@ export default async function setup(project: TestProject) {
1719
let mcpServerProcess: ReturnType<typeof execa> | null = null
1820

1921
// Buffers to store output for potential error display
20-
const appServerOutput: Array<string> = []
21-
const mcpServerOutput: Array<string> = []
22+
const appServerOutput: OutputBuffer = []
23+
const mcpServerOutput: OutputBuffer = []
2224

2325
/**
2426
* Wait for a server to be ready by monitoring its output for a specific text pattern
@@ -32,7 +34,7 @@ export default async function setup(project: TestProject) {
3234
process: ReturnType<typeof execa> | null
3335
textMatch: string
3436
name: string
35-
outputBuffer: Array<string>
37+
outputBuffer: OutputBuffer
3638
}) {
3739
if (!childProcess) return
3840

@@ -42,9 +44,12 @@ export default async function setup(project: TestProject) {
4244
reject(new Error(`${name} failed to start within 10 seconds`))
4345
}, 10_000)
4446

45-
function searchForMatch(data: Buffer) {
47+
function searchForMatch(
48+
channel: OutputBuffer[number]['channel'],
49+
data: Buffer,
50+
) {
51+
outputBuffer.push({ channel, data })
4652
const str = data.toString()
47-
outputBuffer.push(str)
4853
if (str.includes(textMatch)) {
4954
clearTimeout(timeout)
5055
// Remove the listeners after finding the match
@@ -53,8 +58,9 @@ export default async function setup(project: TestProject) {
5358
resolve()
5459
}
5560
}
56-
childProcess?.stdout?.on('data', searchForMatch)
57-
childProcess?.stderr?.on('data', searchForMatch)
61+
62+
childProcess?.stdout?.on('data', searchForMatch.bind(null, 'stdout'))
63+
childProcess?.stderr?.on('data', searchForMatch.bind(null, 'stderr'))
5864

5965
childProcess?.on('error', (err) => {
6066
clearTimeout(timeout)
@@ -76,14 +82,14 @@ export default async function setup(project: TestProject) {
7682
function displayBufferedOutput() {
7783
if (appServerOutput.length > 0) {
7884
console.log('=== App Server Output ===')
79-
for (const line of appServerOutput) {
80-
process.stdout.write(line)
85+
for (const { channel, data } of appServerOutput) {
86+
process[channel].write(data)
8187
}
8288
}
8389
if (mcpServerOutput.length > 0) {
8490
console.log('=== MCP Server Output ===')
85-
for (const line of mcpServerOutput) {
86-
process.stdout.write(line)
91+
for (const { channel, data } of mcpServerOutput) {
92+
process[channel].write(data)
8793
}
8894
}
8995
}
@@ -100,22 +106,20 @@ export default async function setup(project: TestProject) {
100106

101107
// Start the app server from the root directory
102108
console.log(`Starting app server on port 7788...`)
103-
appServerProcess = execa(
104-
'npm',
105-
[
106-
'run',
107-
'dev',
108-
'--prefix',
109-
'./epicshop/epic-me',
110-
'--',
111-
'--clearScreen=false',
112-
'--strictPort',
113-
],
114-
{
115-
cwd: rootDir,
116-
stdio: ['ignore', 'pipe', 'pipe'],
117-
},
118-
)
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+
})
119123
}
120124

121125
async function startServers() {
@@ -148,7 +152,9 @@ export default async function setup(project: TestProject) {
148152
textMatch: '7788',
149153
name: '[APP-SERVER]',
150154
outputBuffer: appServerOutput,
151-
})
155+
}).then(() =>
156+
waitForResourceReady('http://localhost:7788/healthcheck'),
157+
)
152158
: Promise.resolve(),
153159
waitForServerReady({
154160
process: mcpServerProcess,
@@ -175,12 +181,12 @@ export default async function setup(project: TestProject) {
175181
cleanupPromises.push(
176182
(async () => {
177183
mcpServerProcess.kill('SIGTERM')
178-
// Give it 2 seconds to gracefully shutdown, then force kill
184+
// Give it time to gracefully shutdown, then force kill
179185
const timeout = setTimeout(() => {
180186
if (mcpServerProcess && !mcpServerProcess.killed) {
181187
mcpServerProcess.kill('SIGKILL')
182188
}
183-
}, 2000)
189+
}, 500)
184190

185191
try {
186192
await mcpServerProcess
@@ -197,12 +203,12 @@ export default async function setup(project: TestProject) {
197203
cleanupPromises.push(
198204
(async () => {
199205
appServerProcess.kill('SIGTERM')
200-
// Give it 2 seconds to gracefully shutdown, then force kill
206+
// Give time to gracefully shutdown, then force kill
201207
const timeout = setTimeout(() => {
202208
if (appServerProcess && !appServerProcess.killed) {
203209
appServerProcess.kill('SIGKILL')
204210
}
205-
}, 2000)
211+
}, 500)
206212

207213
try {
208214
await appServerProcess
@@ -246,3 +252,41 @@ export default async function setup(project: TestProject) {
246252
// Return cleanup function
247253
return cleanup
248254
}
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

Comments
 (0)