@@ -4,7 +4,7 @@ const path = require('path');
44const fs = require ( 'fs' ) ;
55const { resolveTestServerCommand } = require ( './helpers/tsx-launcher' ) ;
66
7- const TEST_SERVER_PORT = 3001 ;
7+ const { TEST_SERVER_PORT } = require ( './test-server-port' ) ;
88const READY_TIMEOUT_MS = 30000 ;
99const POLL_INTERVAL_MS = 300 ;
1010const READY_PATH = '/api/health' ;
@@ -26,14 +26,14 @@ const GRACE_USER = {
2626 createdAt : '2026-01-01T00:00:00.000Z' ,
2727} ;
2828
29- function writeDefaultAuthState ( ) {
29+ function writeDefaultAuthState ( port = TEST_SERVER_PORT ) {
3030 fs . writeFileSync (
3131 AUTH_STATE_PATH ,
3232 JSON . stringify ( {
3333 cookies : [ ] ,
3434 origins : [
3535 {
36- origin : `http://localhost:${ TEST_SERVER_PORT } ` ,
36+ origin : `http://localhost:${ port } ` ,
3737 localStorage : [
3838 { name : 'dataops_token' , value : 'e2e-bypass-token' } ,
3939 { name : 'dataops_user' , value : JSON . stringify ( GRACE_USER ) } ,
@@ -44,13 +44,13 @@ function writeDefaultAuthState() {
4444 ) ;
4545}
4646
47- function buildTestServerEnvironment ( parentEnvironment = process . env ) {
47+ function buildTestServerEnvironment ( parentEnvironment = process . env , port = TEST_SERVER_PORT ) {
4848 return {
4949 ...parentEnvironment ,
5050 NODE_ENV : 'test' ,
5151 IS_LOCAL : 'true' ,
5252 SKIP_AUTH : 'true' ,
53- PORT : String ( TEST_SERVER_PORT ) ,
53+ PORT : String ( port ) ,
5454 FRONTEND_ROOT : path . resolve ( __dirname , '..' , '..' , 'frontend' ) ,
5555 // Server-owned actor for template-admin tests only. Other route permission
5656 // tests retain their existing explicit actor/no-actor behavior.
@@ -146,7 +146,139 @@ function waitForServer(port, timeoutMs, options = {}) {
146146 } ) ;
147147}
148148
149- async function globalSetup ( ) {
149+ /**
150+ * A health response proves that *a* server owns the fixed port, not that the
151+ * child just spawned owns it. Require that child's successful listen log before
152+ * probing health so a foreign listener cannot satisfy bootstrap.
153+ */
154+ function waitForOwnedServer ( child , port , timeoutMs ) {
155+ const readyMarker = `Test server listening at http://localhost:${ port } ` ;
156+
157+ return new Promise ( ( resolve , reject ) => {
158+ let settled = false ;
159+ let retryTimer ;
160+ let activeRequest ;
161+ const deadline = Date . now ( ) + timeoutMs ;
162+
163+ function cleanup ( ) {
164+ clearTimeout ( retryTimer ) ;
165+ if ( activeRequest ) activeRequest . destroy ( ) ;
166+ child . stdout ?. off ( 'data' , onOutput ) ;
167+ child . stderr ?. off ( 'data' , onErrorOutput ) ;
168+ child . off ( 'error' , onError ) ;
169+ child . off ( 'exit' , onExit ) ;
170+ }
171+
172+ function settle ( operation ) {
173+ if ( settled ) return ;
174+ settled = true ;
175+ cleanup ( ) ;
176+ operation ( ) ;
177+ }
178+
179+ function fail ( message ) {
180+ settle ( ( ) => reject ( new Error ( message ) ) ) ;
181+ }
182+
183+ function succeeded ( ) {
184+ settle ( resolve ) ;
185+ }
186+
187+ function childStatus ( ) {
188+ return `code=${ child . exitCode ?? 'none' } , signal=${ child . signalCode ?? 'none' } ` ;
189+ }
190+
191+ function scheduleProbe ( delayMs ) {
192+ if ( settled ) return ;
193+ if ( child . exitCode !== null || child . signalCode !== null ) {
194+ fail ( `Test server child exited before readiness (${ childStatus ( ) } ).` ) ;
195+ return ;
196+ }
197+
198+ const waitMs = Math . min ( delayMs , Math . max ( 0 , deadline - Date . now ( ) ) ) ;
199+ if ( waitMs <= 0 && retryTimer === undefined ) {
200+ fail (
201+ `The spawned test server announced ${ readyMarker } , but /api/health `
202+ + `did not return HTTP 200 within ${ timeoutMs } ms.`
203+ ) ;
204+ return ;
205+ }
206+ retryTimer = setTimeout ( ( ) => {
207+ retryTimer = undefined ;
208+ probe ( ) ;
209+ } , waitMs ) ;
210+ }
211+
212+ function probe ( ) {
213+ if ( settled ) return ;
214+ let completed = false ;
215+ const request = http . get ( `http://localhost:${ port } ${ READY_PATH } ` , ( response ) => {
216+ if ( completed || settled ) {
217+ response . resume ( ) ;
218+ return ;
219+ }
220+ completed = true ;
221+ activeRequest = undefined ;
222+ response . resume ( ) ;
223+ if ( response . statusCode === 200 ) {
224+ succeeded ( ) ;
225+ return ;
226+ }
227+ scheduleProbe ( POLL_INTERVAL_MS ) ;
228+ } ) ;
229+ request . on ( 'error' , ( ) => {
230+ if ( completed || settled ) return ;
231+ completed = true ;
232+ activeRequest = undefined ;
233+ scheduleProbe ( POLL_INTERVAL_MS ) ;
234+ } ) ;
235+ request . setTimeout ( Math . min ( 1000 , Math . max ( 1 , deadline - Date . now ( ) ) ) , ( ) => {
236+ if ( completed || settled ) return ;
237+ completed = true ;
238+ activeRequest = undefined ;
239+ request . destroy ( ) ;
240+ scheduleProbe ( POLL_INTERVAL_MS ) ;
241+ } ) ;
242+ activeRequest = request ;
243+ }
244+
245+ function onOutput ( chunk ) {
246+ if ( settled ) return ;
247+ if ( String ( chunk ) . includes ( readyMarker ) ) probe ( ) ;
248+ }
249+
250+ function onErrorOutput ( ) { }
251+
252+ function onError ( error ) {
253+ fail ( `Test server child failed before readiness: ${ error . message } ` ) ;
254+ }
255+
256+ function onExit ( ) {
257+ fail ( `Test server child exited before readiness (${ childStatus ( ) } ).` ) ;
258+ }
259+
260+ if ( child . exitCode !== null || child . signalCode !== null ) {
261+ fail ( `Test server child had already exited (${ childStatus ( ) } ).` ) ;
262+ return ;
263+ }
264+
265+ child . stdout ?. setEncoding ( 'utf8' ) ;
266+ child . stderr ?. setEncoding ( 'utf8' ) ;
267+ child . stdout ?. on ( 'data' , onOutput ) ;
268+ child . stderr ?. on ( 'data' , onErrorOutput ) ;
269+ child . once ( 'error' , onError ) ;
270+ child . once ( 'exit' , onExit ) ;
271+ retryTimer = setTimeout ( ( ) => {
272+ retryTimer = undefined ;
273+ fail (
274+ `Test server child did not announce ${ readyMarker } within ${ timeoutMs } ms; `
275+ + 'refusing to use another listener.'
276+ ) ;
277+ } , timeoutMs ) ;
278+ } ) ;
279+ }
280+
281+ async function globalSetup ( { port = TEST_SERVER_PORT } = { } ) {
150282 // Playwright specs also launch isolated test-server children. Keep every
151283 // process in this test-only tree on the same explicit dark rollout state.
152284 Object . assign ( process . env , DARK_ROLLOUT_ENVIRONMENT ) ;
@@ -156,7 +288,7 @@ async function globalSetup() {
156288 const child = spawn (
157289 ...resolveTestServerCommand ( ) ,
158290 {
159- env : buildTestServerEnvironment ( ) ,
291+ env : buildTestServerEnvironment ( undefined , port ) ,
160292 stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
161293 detached : true ,
162294 }
@@ -178,18 +310,20 @@ async function globalSetup() {
178310 // Store the child process so teardown can kill it
179311 globalThis . __testServerProcess = child ;
180312
181- // Wait for the server to be ready before returning control to Playwright
182- await waitForServer ( TEST_SERVER_PORT , READY_TIMEOUT_MS ) ;
313+ // Wait for this spawned process to own the port before returning control to
314+ // Playwright. A foreign HTTP 200 must never satisfy global setup.
315+ await waitForOwnedServer ( child , port , READY_TIMEOUT_MS ) ;
183316
184- console . log ( `[global-setup] Test server is ready on port ${ TEST_SERVER_PORT } ` ) ;
317+ console . log ( `[global-setup] Test server is ready on port ${ port } ` ) ;
185318
186319 // UI tests do not need a server-side session while SKIP_AUTH=true. Use a
187320 // deterministic localStorage session so auth/logout tests cannot invalidate
188321 // the shared browser storage state for unrelated UI tests.
189- writeDefaultAuthState ( ) ;
322+ writeDefaultAuthState ( port ) ;
190323 console . log ( '[global-setup] Auth state saved with test bypass token for Grace' ) ;
191324}
192325
193326module . exports = globalSetup ;
194327module . exports . buildTestServerEnvironment = buildTestServerEnvironment ;
195328module . exports . waitForServer = waitForServer ;
329+ module . exports . waitForOwnedServer = waitForOwnedServer ;
0 commit comments