From 9c10e7e420cb3e5f591b7692952497998d721cc5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 19:28:06 +0800 Subject: [PATCH 1/2] Start integration servers again when another process takes their port Integration tests chose a free port, released it, then spawned a dev or preview server that binds it seconds later. Another process could bind the port in that gap. Vite then moved to the next port, which is often the port another test was just given, and the test kept polling the old port until it timed out after 45 s. startServer() now owns the whole start: it picks a port, spawns the server, and waits for it. Vite servers get --strictPort, so a taken port makes them exit instead of moving into another test's port. When a server reports that its port is taken, startServer() starts it again on a new port, up to three times. Servers that cannot be strict (Astro) are restarted when they report the move. Co-Authored-By: Claude Opus 5.5 (1M context) --- TESTING.md | 5 + .../md/test/integration/markdown-blog.test.ts | 122 ++++++++-------- .../integration/react-pages-client.test.ts | 133 ++++++++---------- .../solid-pages-playground.test.ts | 129 ++++++++--------- .../integration/svelte-pages-client.test.ts | 122 ++++++++-------- packages/void/test/integration/analog.test.ts | 57 ++++---- packages/void/test/integration/astro.test.ts | 62 ++++---- .../test/integration/kitchen-sink.test.ts | 58 ++++---- .../test/integration/named-layouts.test.ts | 61 ++++---- .../void/test/integration/node-target.test.ts | 52 +++---- packages/void/test/integration/nuxt.test.ts | 75 +++++----- .../integration/pages-target-assets.test.ts | 44 +++--- .../void/test/integration/pg-test.test.ts | 64 ++++----- .../test/integration/react-router.test.ts | 61 ++++---- .../integration/spa-middleware-auth.test.ts | 71 +++++----- .../test/integration/ssr-playground.test.ts | 121 +++++++--------- .../void/test/integration/sveltekit.test.ts | 74 +++++----- .../test/integration/tanstack-start.test.ts | 77 +++++----- .../void/test/integration/vinext-app.test.ts | 36 ++--- .../test/integration/vinext-pages.test.ts | 64 ++++----- .../integration/websockets-playground.test.ts | 63 ++++----- .../test/unit/integration-helpers.test.ts | 68 ++++++++- .../test/integration/vue-pages-client.test.ts | 124 ++++++++-------- test/integration-helpers.ts | 119 +++++++++++----- 24 files changed, 920 insertions(+), 942 deletions(-) diff --git a/TESTING.md b/TESTING.md index 5c7d38c3b..3e6f99a7f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -261,6 +261,11 @@ three processes per case and uses 30 seconds on Windows and 10 seconds elsewhere tests retain their default timeout. `waitForServer` already scales its timeout on Windows; callers should pass an unscaled value and leave enough hook time for that wait and setup. +Start integration servers with `startServer` from `test/integration-helpers.ts`, and pass +`--strictPort` to Vite. A server binds its port seconds after the port is chosen, so another +process can take it first. Vite without `--strictPort` then moves to the next port, which can be +another test's port. With it, the server exits and `startServer` starts it again on a new port. + Windows integration tests also use `maxConcurrency: 2`. `maxWorkers` caps files, not concurrent tests inside each file: two CI workers with Vitest's default concurrency of five can open ten isolated browser contexts at once. The Windows cap reduces that to four without serializing diff --git a/packages/md/test/integration/markdown-blog.test.ts b/packages/md/test/integration/markdown-blog.test.ts index 1c74c1776..11f3d91bd 100644 --- a/packages/md/test/integration/markdown-blog.test.ts +++ b/packages/md/test/integration/markdown-blog.test.ts @@ -3,12 +3,10 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { chromium, type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let DEV_PORT: number; let DEV_BASE: string; -let PREVIEW_PORT: number; let PREVIEW_BASE: string; let browser: Browser; @@ -200,36 +198,36 @@ describe('markdown-blog playground', () => { let stderr = ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/markdown-blog', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/markdown-blog', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(DEV_BASE, 20_000); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 20_000); + DEV_BASE = server.base; }); afterAll(async () => { @@ -307,38 +305,36 @@ describe('markdown-blog playground', () => { stdio: 'pipe', }); - PREVIEW_PORT = await getPort(); - PREVIEW_BASE = `http://127.0.0.1:${PREVIEW_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/markdown-blog', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(PREVIEW_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/markdown-blog', + 'preview', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(PREVIEW_BASE, 20_000, { - isAlive: () => child.exitCode === null, - }); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 20_000); + PREVIEW_BASE = server.base; }); afterAll(async () => { diff --git a/packages/react/test/integration/react-pages-client.test.ts b/packages/react/test/integration/react-pages-client.test.ts index 2d55d05c7..62bf107ec 100644 --- a/packages/react/test/integration/react-pages-client.test.ts +++ b/packages/react/test/integration/react-pages-client.test.ts @@ -5,12 +5,7 @@ import spawn from 'cross-spawn'; import { type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; -import { - collectServerOutput, - getPort, - waitForServer, - killChild, -} from '../../../../test/integration-helpers.ts'; +import { killChild, startServer, waitForServer } from '../../../../test/integration-helpers.ts'; import { launchPagesBrowser } from '../../../../test/pages-browser.ts'; import { registerSharedClientTests } from '../../../../test/pages-client-contract.ts'; import { defineSharedIslandTests } from '../../../../test/pages-island-contract.ts'; @@ -41,9 +36,7 @@ const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const REACT_PAGES_ROOT = join(ROOT, 'playground/react-pages'); const REACT_PAGES_LAYOUT = join(REACT_PAGES_ROOT, 'pages/layout.tsx'); const LATE_WORKER_DEPS_ROOT = join(REACT_PAGES_ROOT, 'node_modules/@void-repro'); -let DEV_PORT: number; let DEV_BASE: string; -let PREVIEW_PORT: number; let PREVIEW_BASE: string; let browser: Browser; @@ -615,40 +608,37 @@ describe('react-pages client (dev)', () => { writeFileSync(REACT_PAGES_LAYOUT, lateWorkerDepLayout()); rmSync(join(REACT_PAGES_ROOT, 'node_modules/.vite'), { recursive: true, force: true }); - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/react-pages', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - const serverTimeout = process.platform === 'win32' ? 45_000 : 20_000; - await waitForServer(DEV_BASE, serverTimeout, { - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: collectServerOutput(child), - }); + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/react-pages', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, serverTimeout); + DEV_BASE = server.base; const paths = ['/', '/users', '/action-error', '/router-params/alpha', '/visit-await']; coldStartResponses.push( ...(await Promise.all( @@ -1099,39 +1089,36 @@ describe('react-pages client (preview)', { retry: 2 }, () => { }); } - PREVIEW_PORT = await getPort(); - PREVIEW_BASE = `http://127.0.0.1:${PREVIEW_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/react-pages', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(PREVIEW_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/react-pages', + 'preview', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(PREVIEW_BASE, 30_000, { - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: collectServerOutput(child), - }); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 30_000); + PREVIEW_BASE = server.base; }); afterAll(async () => { diff --git a/packages/solid/test/integration/solid-pages-playground.test.ts b/packages/solid/test/integration/solid-pages-playground.test.ts index 04886ee87..f958f5640 100644 --- a/packages/solid/test/integration/solid-pages-playground.test.ts +++ b/packages/solid/test/integration/solid-pages-playground.test.ts @@ -5,12 +5,7 @@ import spawn from 'cross-spawn'; import { type Browser, type Page } from 'playwright'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; -import { - collectServerOutput, - getPort, - waitForServer, - killChild, -} from '../../../../test/integration-helpers.ts'; +import { killChild, startServer, waitForServer } from '../../../../test/integration-helpers.ts'; import { launchPagesBrowser } from '../../../../test/pages-browser.ts'; import { registerSharedClientTests } from '../../../../test/pages-client-contract.ts'; import { defineSharedIslandTests } from '../../../../test/pages-island-contract.ts'; @@ -28,9 +23,7 @@ declare global { } const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let DEV_PORT: number; let DEV_BASE: string; -let PREVIEW_PORT: number; let PREVIEW_BASE: string; let browser: Browser; @@ -178,39 +171,37 @@ describe('solid-pages client (dev)', () => { let serverOutput = () => ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/solid-pages', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, + const server = await startServer( + (port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/solid-pages', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + return child; }, + 45_000, + { expectStatus: 200, expectBody: '__VOID_PAGE_DATA__' }, ); - - serverOutput = collectServerOutput(child); - - await waitForServer(DEV_BASE, 45_000, { - expectStatus: 200, - expectBody: '__VOID_PAGE_DATA__', - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: serverOutput, - }); + DEV_BASE = server.base; + serverOutput = server.output; }); afterAll(async () => { @@ -556,38 +547,36 @@ describe('solid-pages client (preview)', { retry: 2 }, () => { }); } - PREVIEW_PORT = await getPort(); - PREVIEW_BASE = `http://127.0.0.1:${PREVIEW_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/solid-pages', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(PREVIEW_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/solid-pages', + 'preview', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(PREVIEW_BASE, 30_000, { - isAlive: () => child.exitCode === null, - }); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 30_000); + PREVIEW_BASE = server.base; }); afterAll(async () => { diff --git a/packages/svelte/test/integration/svelte-pages-client.test.ts b/packages/svelte/test/integration/svelte-pages-client.test.ts index ede6a6cf6..0a666c662 100644 --- a/packages/svelte/test/integration/svelte-pages-client.test.ts +++ b/packages/svelte/test/integration/svelte-pages-client.test.ts @@ -5,7 +5,7 @@ import spawn from 'cross-spawn'; import { type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer, waitForServer } from '../../../../test/integration-helpers.ts'; import { launchPagesBrowser } from '../../../../test/pages-browser.ts'; import { registerSharedClientTests } from '../../../../test/pages-client-contract.ts'; import { defineSharedIslandTests } from '../../../../test/pages-island-contract.ts'; @@ -23,9 +23,7 @@ declare global { type NavElement = HTMLElement & { __stamp?: number }; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let DEV_PORT: number; let DEV_BASE: string; -let PREVIEW_PORT: number; let PREVIEW_BASE: string; let browser: Browser; @@ -139,36 +137,36 @@ describe('svelte-pages client (dev)', () => { let stderr = ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/svelte-pages', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/svelte-pages', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(DEV_BASE, 20_000); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 20_000); + DEV_BASE = server.base; }); afterAll(async () => { @@ -498,38 +496,36 @@ describe('svelte-pages client (preview)', { retry: 2 }, () => { }); } - PREVIEW_PORT = await getPort(); - PREVIEW_BASE = `http://127.0.0.1:${PREVIEW_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/svelte-pages', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(PREVIEW_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/svelte-pages', + 'preview', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(PREVIEW_BASE, 30_000, { - isAlive: () => child.exitCode === null, - }); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 30_000); + PREVIEW_BASE = server.base; }); afterAll(async () => { diff --git a/packages/void/test/integration/analog.test.ts b/packages/void/test/integration/analog.test.ts index 316540f95..3857cb09b 100644 --- a/packages/void/test/integration/analog.test.ts +++ b/packages/void/test/integration/analog.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { parse } from 'jsonc-parser'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/analog'; @@ -42,7 +42,6 @@ afterAll(() => { describe('playground/analog dev', () => { let child: ReturnType; - let DEV_PORT: number; let DEV_BASE: string; beforeAll(async () => { @@ -52,40 +51,38 @@ describe('playground/analog dev', () => { rmSync(join(pg, 'dist/.nitro'), { recursive: true, force: true }); removeWranglerConfig(pg); - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - // Analog skips its Nitro dev server when VITEST or NODE_ENV=test is set, so start // the child the way `vp dev` runs for users. const { VITEST: _vitest, NODE_ENV: _nodeEnv, ...userEnv } = process.env; - child = spawn( - 'vp', - [ - 'run', - '--filter', - `./${PLAYGROUND}`, - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...userEnv, - VOID_INSPECTOR_PORT: 'false', - }, - }, - ); - - child.stderr!.on('data', () => {}); - // Analog's first dev boot compiles the Angular app. Under a fully parallel test run that can // exceed the normal 30s readiness window even though subsequent requests are fast. - await waitForServer(DEV_BASE, 60_000); + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...userEnv, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + return child; + }, 60_000); + DEV_BASE = server.base; }); afterAll(async () => { diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index f7a57e093..0c54758a2 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -2,16 +2,10 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { - collectServerOutput, - getPort, - waitForServer, - killChild, -} from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/astro'; -let DEV_PORT: number; let DEV_BASE: string; function readWranglerConfig(root: string): { @@ -26,36 +20,32 @@ describe('playground/astro dev', () => { let child: ReturnType; const startDev = async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - `./${PLAYGROUND}`, - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - ASTRO_DEV_BACKGROUND: 'false', - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + ASTRO_DEV_BACKGROUND: 'false', + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - await waitForServer(DEV_BASE, 20_000, { - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: collectServerOutput(child), - }); + ); + return child; + }, 20_000); + DEV_BASE = server.base; }; beforeAll(async () => { diff --git a/packages/void/test/integration/kitchen-sink.test.ts b/packages/void/test/integration/kitchen-sink.test.ts index 58e8978f4..03348c0bf 100644 --- a/packages/void/test/integration/kitchen-sink.test.ts +++ b/packages/void/test/integration/kitchen-sink.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { chromium, type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; declare global { interface Window { @@ -16,7 +16,6 @@ type NavElement = HTMLElement & { __stamp?: number }; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let PORT: number; let BASE: string; let browser: Browser; @@ -43,9 +42,6 @@ describe('playground/kitchen-sink', () => { let child: ReturnType; beforeAll(async () => { - PORT = await getPort(); - BASE = `http://127.0.0.1:${PORT}`; - // Build first so every dependency is resolved before the server starts; // dev-mode dependency optimization makes browser checks unreliable. const pg = join(ROOT, 'playground/kitchen-sink'); @@ -57,32 +53,36 @@ describe('playground/kitchen-sink', () => { const previewDatabaseId = preparePreviewWranglerConfig(pg); // Start preview server — pre-built assets, no lazy optimization. - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/kitchen-sink', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, + const server = await startServer( + (port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/kitchen-sink', + 'preview', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + return child; }, + 30_000, + { expectBody: 'Kitchen Sink' }, ); - - await waitForServer(BASE, 30_000, { - expectBody: 'Kitchen Sink', - isAlive: () => child.exitCode === null && child.signalCode === null, - }); + BASE = server.base; // Preview mode doesn't auto-run migrations (dev mode's migrationPlugin // handles this via configureServer). Apply them directly to the local diff --git a/packages/void/test/integration/named-layouts.test.ts b/packages/void/test/integration/named-layouts.test.ts index 4fb34b7fe..f44ea86f0 100644 --- a/packages/void/test/integration/named-layouts.test.ts +++ b/packages/void/test/integration/named-layouts.test.ts @@ -1,10 +1,9 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let DEV_PORT: number; let DEV_BASE: string; describe('named-layouts integration', () => { @@ -12,36 +11,36 @@ describe('named-layouts integration', () => { let stderr = ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/named-layouts', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/named-layouts', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(DEV_BASE, 20_000); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 20_000); + DEV_BASE = server.base; }); afterAll(async () => { diff --git a/packages/void/test/integration/node-target.test.ts b/packages/void/test/integration/node-target.test.ts index fa86c945e..247bff3b7 100644 --- a/packages/void/test/integration/node-target.test.ts +++ b/packages/void/test/integration/node-target.test.ts @@ -4,16 +4,13 @@ import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { createIsolatedPlayground, - getPort, - waitForServer, killChild, + startServer, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let DEV_PORT: number; let DEV_BASE: string; -let PROD_PORT: number; let PROD_BASE: string; describe('playground/node-app dev', () => { @@ -23,20 +20,18 @@ describe('playground/node-app dev', () => { beforeAll(async () => { playground = createIsolatedPlayground(ROOT, 'playground/node-app'); - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(DEV_PORT)], { - cwd: playground.root, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - child.stdout!.resume(); - - await waitForServer(DEV_BASE, 20_000); + const server = await startServer((port) => { + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + }); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 20_000); + DEV_BASE = server.base; }); afterAll(async () => { @@ -101,18 +96,15 @@ describe('playground/node-app build + production', () => { }); expect(build.status, `${build.stdout ?? ''}${build.stderr ?? ''}`).toBe(0); - PROD_PORT = await getPort(); - PROD_BASE = `http://127.0.0.1:${PROD_PORT}`; - - child = spawn('node', ['dist/ssr/index.js'], { - cwd: playground.root, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, PORT: String(PROD_PORT) }, - }); - - await waitForServer(PROD_BASE, 20_000, { - isAlive: () => child.exitCode === null, - }); + const server = await startServer((port) => { + child = spawn('node', ['dist/ssr/index.js'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PORT: String(port) }, + }); + return child; + }, 20_000); + PROD_BASE = server.base; }); afterAll(async () => { diff --git a/packages/void/test/integration/nuxt.test.ts b/packages/void/test/integration/nuxt.test.ts index 15d769444..ff0b541ef 100644 --- a/packages/void/test/integration/nuxt.test.ts +++ b/packages/void/test/integration/nuxt.test.ts @@ -3,11 +3,10 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { collectBrowserDiagnostics } from '../../../../test/browser-helpers.ts'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/nuxt'; -let DEV_PORT: number; let DEV_BASE: string; function removeWranglerConfig(root: string) { @@ -19,7 +18,7 @@ describe( 'playground/nuxt dev', () => { let child: ReturnType; - let serverOutput = ''; + let serverOutput = () => ''; beforeAll( async () => { @@ -28,46 +27,38 @@ describe( rmSync(join(pg, '.wrangler'), { recursive: true, force: true }); removeWranglerConfig(pg); - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - `./${PLAYGROUND}`, - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, + // Nuxt has no strict port option: if its port is taken, it moves to a random port + // without saying so, and this start times out instead of retrying. + const server = await startServer( + (port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + return child; }, + 30_000, + { expectStatus: 200, expectBody: 'Nuxt + Void' }, ); - - const recordOutput = (chunk: Buffer) => { - serverOutput = (serverOutput + String(chunk)).slice(-20_000); - }; - child.stdout!.on('data', recordOutput); - child.stderr!.on('data', recordOutput); - - try { - await waitForServer(DEV_BASE, 30_000, { - expectStatus: 200, - expectBody: 'Nuxt + Void', - isAlive: () => child.exitCode === null && child.signalCode === null, - }); - } catch (error) { - throw new Error(`Nuxt failed to start:\n${serverOutput}`, { cause: error }); - } + DEV_BASE = server.base; + serverOutput = server.output; }, process.platform === 'win32' ? 120_000 : 45_000, ); @@ -144,7 +135,7 @@ describe( expect(d1User).toBe('Alice'); expect(kvValue).toBe('ok'); } catch (error) { - throw new Error(`${diagnostics()}\nDev server:\n${serverOutput}`, { cause: error }); + throw new Error(`${diagnostics()}\nDev server:\n${serverOutput()}`, { cause: error }); } finally { await browser.close(); } diff --git a/packages/void/test/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index 61d32161c..3e432badc 100644 --- a/packages/void/test/integration/pages-target-assets.test.ts +++ b/packages/void/test/integration/pages-target-assets.test.ts @@ -5,11 +5,9 @@ import spawn from 'cross-spawn'; import { chromium } from 'playwright'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { - collectServerOutput, createIsolatedPlayground, - getPort, killChild, - waitForServer, + startServer, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; import { renderVoidConfig } from '../../src/config-write.ts'; @@ -74,17 +72,15 @@ export default function Home() { mkdirSync(join(root, 'public')); writeFileSync(join(root, 'public/health.txt'), 'public asset'); execFileSync('vp', ['build'], { cwd: root, stdio: 'pipe' }); - const port = await getPort(); - base = `http://127.0.0.1:${port}`; - child = spawn(command, [...args, 'dist/ssr/index.js'], { - cwd: root, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, PORT: String(port) }, - }); - await waitForServer(base, 20_000, { - isAlive: () => child.exitCode === null, - diagnostics: collectServerOutput(child), - }); + const server = await startServer((port) => { + child = spawn(command, [...args, 'dist/ssr/index.js'], { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PORT: String(port) }, + }); + return child; + }, 20_000); + base = server.base; }); afterAll(async () => { @@ -130,22 +126,24 @@ export default function Home() { it('resolves assets from the emitted module when the working directory changes', async () => { const elsewhere = join(root, 'another-working-directory'); mkdirSync(elsewhere); - const port = await getPort(); - const relocatedBase = `http://127.0.0.1:${port}`; - const relocated = spawn(command, [...args, join(root, 'dist/ssr/index.js')], { - cwd: elsewhere, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, PORT: String(port) }, - }); + const relocated = await startServer( + (port) => + spawn(command, [...args, join(root, 'dist/ssr/index.js')], { + cwd: elsewhere, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PORT: String(port) }, + }), + 20_000, + ); + const relocatedBase = relocated.base; try { - await waitForServer(relocatedBase, 20_000, { isAlive: () => relocated.exitCode === null }); const html = await (await fetch(relocatedBase)).text(); const script = html.match(/]*src="([^"]+)"/); expect(script).not.toBeNull(); expect((await fetch(new URL(script![1], relocatedBase))).status).toBe(200); expect(await (await fetch(`${relocatedBase}/health.txt`)).text()).toBe('public asset'); } finally { - await killChild(relocated); + await killChild(relocated.child); } }); diff --git a/packages/void/test/integration/pg-test.test.ts b/packages/void/test/integration/pg-test.test.ts index c8c086adb..762919c27 100644 --- a/packages/void/test/integration/pg-test.test.ts +++ b/packages/void/test/integration/pg-test.test.ts @@ -5,12 +5,7 @@ import { PGlite } from '@electric-sql/pglite'; import spawn from 'cross-spawn'; import { createServer } from 'pglite-server'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { - collectServerOutput, - getPort, - waitForServer, - killChild, -} from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const ENV_PATH = join(ROOT, 'playground/pg-test/.env'); @@ -28,7 +23,6 @@ describe('playground/pg-test', () => { let pgServer: ReturnType; let pgPort: number; let devChild: ReturnType; - let devPort: number; let BASE: string; let serverOutput = () => ''; @@ -55,37 +49,33 @@ describe('playground/pg-test', () => { writeFileSync(ENV_PATH, `DATABASE_URL=${databaseUrl}\n`); // 3. Start vite dev server - devPort = await getPort(); - BASE = `http://127.0.0.1:${devPort}`; - - devChild = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/pg-test', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(devPort), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + devChild = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/pg-test', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - serverOutput = collectServerOutput(devChild); - - await waitForServer(BASE, 30_000, { - isAlive: () => devChild.exitCode === null && devChild.signalCode === null, - diagnostics: serverOutput, - }); + ); + return devChild; + }, 30_000); + BASE = server.base; + serverOutput = server.output; }); afterAll(async () => { diff --git a/packages/void/test/integration/react-router.test.ts b/packages/void/test/integration/react-router.test.ts index 383e5472b..f3107782d 100644 --- a/packages/void/test/integration/react-router.test.ts +++ b/packages/void/test/integration/react-router.test.ts @@ -1,11 +1,10 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/react-router'; -let DEV_PORT: number; let DEV_BASE: string; describe('playground/react-router dev', { retry: 2 }, () => { @@ -13,36 +12,36 @@ describe('playground/react-router dev', { retry: 2 }, () => { let stderr = ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - `./${PLAYGROUND}`, - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(DEV_BASE, 30_000); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 30_000); + DEV_BASE = server.base; }); afterAll(async () => { diff --git a/packages/void/test/integration/spa-middleware-auth.test.ts b/packages/void/test/integration/spa-middleware-auth.test.ts index 27d7e7267..b72aa48e2 100644 --- a/packages/void/test/integration/spa-middleware-auth.test.ts +++ b/packages/void/test/integration/spa-middleware-auth.test.ts @@ -5,10 +5,9 @@ import spawn from 'cross-spawn'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vite-plus/test'; import { createIsolatedPlayground, - getPort, killChild, + startServer, type IsolatedPlayground, - waitForServer, } from '../../../../test/integration-helpers.ts'; import { renderVoidConfig } from '../../src/config-write.ts'; import { readConfig } from '../../src/config.ts'; @@ -111,44 +110,42 @@ function writeProtectedAsset(): void { } async function startSpaDevServer(): Promise { - const port = await getPort(); - const base = `http://127.0.0.1:${port}`; - stderr = ''; - child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port)], { - cwd: playground.root, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, - }); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(base, 30_000, { - isAlive: () => child?.exitCode === null && child.signalCode === null, - }); - return base; + const server = await startServer((port) => { + stderr = ''; + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }); + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 30_000); + return server.base; } async function startSpaPreviewServer(): Promise { - const port = await getPort(); - const base = `http://127.0.0.1:${port}`; - stderr = ''; - child = spawn('vp', ['preview', '--host', '127.0.0.1', '--port', String(port)], { - cwd: playground.root, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, - }); - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - await waitForServer(base, 30_000, { - isAlive: () => child?.exitCode === null && child.signalCode === null, - }); - return base; + const server = await startServer((port) => { + stderr = ''; + child = spawn( + 'vp', + ['preview', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], + { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }, + ); + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 30_000); + return server.base; } function readBuiltAssetConfig(): { diff --git a/packages/void/test/integration/ssr-playground.test.ts b/packages/void/test/integration/ssr-playground.test.ts index 0ec7a8c6d..aabcd10ce 100644 --- a/packages/void/test/integration/ssr-playground.test.ts +++ b/packages/void/test/integration/ssr-playground.test.ts @@ -3,17 +3,10 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { - collectServerOutput, - getPort, - waitForServer, - killChild, -} from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let DEV_PORT: number; let DEV_BASE: string; -let PREVIEW_PORT: number; let PREVIEW_BASE: string; describe('playground/ssr dev', () => { @@ -21,37 +14,33 @@ describe('playground/ssr dev', () => { let serverOutput = () => ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/ssr', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/ssr', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - serverOutput = collectServerOutput(child); - - await waitForServer(DEV_BASE, 20_000, { - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: serverOutput, - }); + ); + return child; + }, 20_000); + DEV_BASE = server.base; + serverOutput = server.output; }); afterAll(async () => { @@ -93,37 +82,33 @@ describe('playground/ssr build + preview', () => { rmSync(join(ROOT, 'playground/ssr/dist'), { recursive: true, force: true }); execSync('vp run --filter ./playground/ssr build', { cwd: ROOT, stdio: 'pipe' }); - PREVIEW_PORT = await getPort(); - PREVIEW_BASE = `http://127.0.0.1:${PREVIEW_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/ssr', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(PREVIEW_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/ssr', + 'preview', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - serverOutput = collectServerOutput(child); - - await waitForServer(PREVIEW_BASE, 20_000, { - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: serverOutput, - }); + ); + return child; + }, 20_000); + PREVIEW_BASE = server.base; + serverOutput = server.output; }); afterAll(async () => { diff --git a/packages/void/test/integration/sveltekit.test.ts b/packages/void/test/integration/sveltekit.test.ts index ea26b3bd1..c36b2e098 100644 --- a/packages/void/test/integration/sveltekit.test.ts +++ b/packages/void/test/integration/sveltekit.test.ts @@ -5,9 +5,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { collectBrowserDiagnostics } from '../../../../test/browser-helpers.ts'; import { createIsolatedPlayground, - getPort, - waitForServer, killChild, + startServer, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; @@ -17,7 +16,7 @@ describe.each([2, 3])('SvelteKit %i compatibility', (major) => { let child: ReturnType; let fixture: IsolatedPlayground; let DEV_BASE: string; - let serverOutput = ''; + let serverOutput = () => ''; beforeAll(async () => { fixture = createIsolatedPlayground(ROOT, PLAYGROUND); @@ -77,46 +76,37 @@ export function checkSchema() { ); expect(prepare.status, prepare.stdout + prepare.stderr).toBe(0); - const DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - // Kit 3 checks Vite environment identity; use the fixture's Vite+ instance // even when the smart runner puts the workspace's binaries first on PATH. - child = spawn( - process.execPath, - [ - join(pg, 'node_modules/vite-plus/bin/vp'), - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: pg, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, + const server = await startServer( + (port) => { + child = spawn( + process.execPath, + [ + join(pg, 'node_modules/vite-plus/bin/vp'), + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: pg, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + return child; }, + 30_000, + { expectStatus: 200, expectBody: 'SvelteKit + Void' }, ); - - const recordOutput = (chunk: Buffer) => { - serverOutput = (serverOutput + String(chunk)).slice(-20_000); - }; - child.stdout!.on('data', recordOutput); - child.stderr!.on('data', recordOutput); - - try { - await waitForServer(DEV_BASE, 30_000, { - expectStatus: 200, - expectBody: 'SvelteKit + Void', - isAlive: () => child.exitCode === null && child.signalCode === null, - }); - } catch (error) { - throw new Error(`SvelteKit failed to start:\n${serverOutput}`, { cause: error }); - } + DEV_BASE = server.base; + serverOutput = server.output; }); afterAll(async () => { @@ -124,8 +114,8 @@ export function checkSchema() { await killChild(child); } fixture?.cleanup(); - if (serverOutput.includes('error when starting dev server')) { - throw new Error(serverOutput); + if (serverOutput().includes('error when starting dev server')) { + throw new Error(serverOutput()); } }); @@ -189,7 +179,7 @@ export function checkSchema() { expect(d1User).toBe('Alice'); expect(kvValue).toBe('ok'); } catch (error) { - throw new Error(`${diagnostics()}\nDev server:\n${serverOutput}`, { cause: error }); + throw new Error(`${diagnostics()}\nDev server:\n${serverOutput()}`, { cause: error }); } finally { await browser.close(); } diff --git a/packages/void/test/integration/tanstack-start.test.ts b/packages/void/test/integration/tanstack-start.test.ts index 7984e5700..981069075 100644 --- a/packages/void/test/integration/tanstack-start.test.ts +++ b/packages/void/test/integration/tanstack-start.test.ts @@ -1,16 +1,10 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { - collectServerOutput, - getPort, - killChild, - waitForServer, -} from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/tanstack-start'; -let DEV_PORT: number; let DEV_BASE: string; describe('playground/tanstack-start dev', () => { @@ -18,43 +12,42 @@ describe('playground/tanstack-start dev', () => { let stderr = ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - `./${PLAYGROUND}`, - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - '--strictPort', - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, + // Another process can answer on the port before this server starts, + // so only this app's page counts as ready. + const server = await startServer( + (port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; }, + 20_000, + { expectBody: 'TanStack Start + Void' }, ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - // Other test files in the same run can bind the port getPort() released, - // so only this app's page counts as ready. - await waitForServer(DEV_BASE, 20_000, { - expectBody: 'TanStack Start + Void', - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: collectServerOutput(child), - }); + DEV_BASE = server.base; }); afterAll(async () => { diff --git a/packages/void/test/integration/vinext-app.test.ts b/packages/void/test/integration/vinext-app.test.ts index ad11face1..f794015cd 100644 --- a/packages/void/test/integration/vinext-app.test.ts +++ b/packages/void/test/integration/vinext-app.test.ts @@ -5,17 +5,14 @@ import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vite-plus/test'; import { createIsolatedPlayground, - getPort, - waitForServer, killChild, - collectServerOutput, + startServer, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; import { collectWorkerFiles } from '../../src/cli/package.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/vinext-app'; -let DEV_PORT: number; let DEV_BASE: string; describe('playground/vinext-app dev', () => { @@ -25,24 +22,19 @@ describe('playground/vinext-app dev', () => { beforeAll(async () => { playground = createIsolatedPlayground(ROOT, PLAYGROUND); - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(DEV_PORT)], { - cwd: playground.root, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, - }); - - serverOutput = collectServerOutput(child); - - await waitForServer(DEV_BASE, 30_000, { - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: serverOutput, - }); + const server = await startServer((port) => { + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }); + return child; + }, 30_000); + DEV_BASE = server.base; + serverOutput = server.output; }); afterAll(async () => { diff --git a/packages/void/test/integration/vinext-pages.test.ts b/packages/void/test/integration/vinext-pages.test.ts index 93b22985e..e4d40bc8b 100644 --- a/packages/void/test/integration/vinext-pages.test.ts +++ b/packages/void/test/integration/vinext-pages.test.ts @@ -1,16 +1,10 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { - getPort, - waitForServer, - killChild, - collectServerOutput, -} from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/vinext-pages'; -let DEV_PORT: number; let DEV_BASE: string; describe('playground/vinext-pages dev', () => { @@ -18,37 +12,33 @@ describe('playground/vinext-pages dev', () => { let serverOutput = () => ''; beforeAll(async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - `./${PLAYGROUND}`, - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + `./${PLAYGROUND}`, + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - serverOutput = collectServerOutput(child); - - await waitForServer(DEV_BASE, 20_000, { - isAlive: () => child.exitCode === null && child.signalCode === null, - diagnostics: serverOutput, - }); + ); + return child; + }, 20_000); + DEV_BASE = server.base; + serverOutput = server.output; }); afterAll(async () => { diff --git a/packages/void/test/integration/websockets-playground.test.ts b/packages/void/test/integration/websockets-playground.test.ts index 5cc057d16..9f726843c 100644 --- a/packages/void/test/integration/websockets-playground.test.ts +++ b/packages/void/test/integration/websockets-playground.test.ts @@ -3,12 +3,11 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { chromium, type Browser, type Page } from 'playwright'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { getPort, killChild, waitForServer } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); let browser: Browser; -let port: number; let base: string; async function newPage(): Promise { @@ -85,37 +84,37 @@ describe('playground/websockets', () => { browser = await chromium.launch(); - port = await getPort(); - base = `http://127.0.0.1:${port}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/websockets', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(port), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VITE_VOID_NOTIFICATION_STREAM_DELAY_MS: '10', - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/websockets', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VITE_VOID_NOTIFICATION_STREAM_DELAY_MS: '10', + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(base, 20_000); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 20_000); + base = server.base; }); it('builds WebSocket routes as SQLite-backed Durable Objects', () => { diff --git a/packages/void/test/unit/integration-helpers.test.ts b/packages/void/test/unit/integration-helpers.test.ts index dfa45f668..c56aa3934 100644 --- a/packages/void/test/unit/integration-helpers.test.ts +++ b/packages/void/test/unit/integration-helpers.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, globSync, @@ -11,14 +11,80 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; +import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { isAbsolute, join, relative } from 'node:path'; import { describe, expect, it } from 'vite-plus/test'; import { createIsolatedPlayground, + killChild, shouldCopyPlaygroundPath, + startServer, } from '../../../../test/integration-helpers.ts'; +// Answers HTTP on 127.0.0.1: after a short delay, like a dev server that is still starting. +const SERVER = `setTimeout(() => { + require('node:http').createServer((_, res) => res.end('ok')).listen(Number(process.argv[1]), '127.0.0.1'); +}, 200);`; + +describe('startServer', () => { + it('starts the server again on a new port when another process takes its port first', async () => { + const ports: Array = []; + let other: Server | undefined; + try { + const server = await startServer((port) => { + ports.push(port); + if (!other) { + other = createServer((socket) => socket.destroy()).listen(port, '127.0.0.1'); + } + return spawn(process.execPath, ['-e', SERVER, String(port)], { stdio: 'pipe' }); + }, 10_000); + try { + expect(ports).toHaveLength(2); + expect(server.port).toBe(ports[1]); + expect(server.port).not.toBe(ports[0]); + expect(await (await fetch(server.base)).text()).toBe('ok'); + } finally { + await killChild(server.child); + } + } finally { + other?.close(); + } + }); + + it('starts the server again when it moves to another port instead of exiting', async () => { + const ports: Array = []; + const server = await startServer((port) => { + ports.push(port); + // What Vite without --strictPort prints before it listens on the next port. + const script = + ports.length === 1 + ? `console.log('Port ${port} is in use, trying another one...'); setInterval(() => {}, 1000);` + : SERVER; + return spawn(process.execPath, ['-e', script, String(port)], { stdio: 'pipe' }); + }, 10_000); + try { + expect(ports).toHaveLength(2); + expect(await (await fetch(server.base)).text()).toBe('ok'); + } finally { + await killChild(server.child); + } + }); + + it('does not retry a server that fails for another reason', async () => { + let starts = 0; + await expect( + startServer(() => { + starts++; + return spawn(process.execPath, ['-e', 'console.error("config error"); process.exit(1)'], { + stdio: 'pipe', + }); + }, 10_000), + ).rejects.toThrow(/Server stopped while waiting[\s\S]*config error/); + expect(starts).toBe(1); + }); +}); + describe('shouldCopyPlaygroundPath', () => { it('excludes generated top-level entries from native Windows paths', () => { const sourceRoot = String.raw`D:\a\void\void\playground\spa`; diff --git a/packages/vue/test/integration/vue-pages-client.test.ts b/packages/vue/test/integration/vue-pages-client.test.ts index cf2f60ad4..b38df5a19 100644 --- a/packages/vue/test/integration/vue-pages-client.test.ts +++ b/packages/vue/test/integration/vue-pages-client.test.ts @@ -5,7 +5,7 @@ import spawn from 'cross-spawn'; import { type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { killChild, startServer, waitForServer } from '../../../../test/integration-helpers.ts'; import { launchPagesBrowser } from '../../../../test/pages-browser.ts'; import { registerSharedClientTests } from '../../../../test/pages-client-contract.ts'; import { defineSharedIslandTests } from '../../../../test/pages-island-contract.ts'; @@ -24,9 +24,7 @@ declare global { type NavElement = HTMLElement & { __stamp?: number }; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -let DEV_PORT: number; let DEV_BASE: string; -let PREVIEW_PORT: number; let PREVIEW_BASE: string; let browser: Browser; @@ -216,37 +214,37 @@ describe('vue-pages client (dev)', () => { beforeAll( async () => { - DEV_PORT = await getPort(); - DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/vue-pages', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - const serverTimeout = process.platform === 'win32' ? 45_000 : 20_000; - await waitForServer(DEV_BASE, serverTimeout); + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/vue-pages', + 'dev', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, serverTimeout); + DEV_BASE = server.base; }, process.platform === 'win32' ? 60_000 : 30_000, ); @@ -661,38 +659,36 @@ describe('vue-pages client (preview)', { retry: 2 }, () => { }); } - PREVIEW_PORT = await getPort(); - PREVIEW_BASE = `http://127.0.0.1:${PREVIEW_PORT}`; - - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/vue-pages', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(PREVIEW_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', + const server = await startServer((port) => { + child = spawn( + 'vp', + [ + 'run', + '--filter', + './playground/vue-pages', + 'preview', + '--host', + '127.0.0.1', + '--port', + String(port), + '--strictPort', + ], + { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', + }, }, - }, - ); - - child.stderr!.on('data', (chunk: Buffer) => { - stderr += String(chunk); - }); - - await waitForServer(PREVIEW_BASE, 30_000, { - isAlive: () => child.exitCode === null, - }); + ); + stderr = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += String(chunk); + }); + return child; + }, 30_000); + PREVIEW_BASE = server.base; }); afterAll(async () => { diff --git a/test/integration-helpers.ts b/test/integration-helpers.ts index fa7032d3d..b8b2df786 100644 --- a/test/integration-helpers.ts +++ b/test/integration-helpers.ts @@ -12,8 +12,9 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; -import { createServer, type Server } from 'node:net'; +import { createServer, type AddressInfo } from 'node:net'; import { basename, join, relative } from 'node:path/posix'; +import { setTimeout as delay } from 'node:timers/promises'; /** * Timeout multiplier for Windows CI runners, which are 2–3× slower @@ -21,13 +22,8 @@ import { basename, join, relative } from 'node:path/posix'; */ const CI_TIMEOUT_SCALE = process.platform === 'win32' ? 3 : 1; -/** - * Ports held by getPort() until waitForServer() releases them. - * Prevents TOCTOU races when many integration tests run in parallel — - * without this, another test's getPort() can grab the same port - * during the gap between close and the child process's listen(). - */ -const heldServers = new Map(); +/** How many ports startServer() tries before it gives up. */ +const START_ATTEMPTS = 3; const GENERATED_PLAYGROUND_ENTRIES = new Set([ '.astro', @@ -138,51 +134,96 @@ export function createIsolatedPlayground( }; } -/** - * Returns an unused port by binding to port 0 and reading the - * OS-assigned port. The underlying socket stays open until - * waitForServer() is called with a URL on that port, preventing - * other parallel tests from stealing it. - */ -export async function getPort(): Promise { +/** Returns a port that is free right now. Another process can still take it before use. */ +async function getFreePort(): Promise { const server = createServer(); server.listen(0, '127.0.0.1'); await once(server, 'listening'); - const { port } = server.address() as import('node:net').AddressInfo; - server.unref(); // don't block process exit if beforeAll throws before waitForServer - heldServers.set(port, server); + const { port } = server.address() as AddressInfo; + server.close(); + await once(server, 'close'); return port; } +export type WaitForServerOptions = { + expectBody?: string; + rejectBody?: string; + expectStatus?: number; + isAlive?: () => boolean; + requestTimeoutMs?: number; + diagnostics?: () => string; +}; + +export type StartedServer = { + child: ReturnType; + port: number; + /** `http://127.0.0.1:` */ + base: string; + /** The server's recent stdout and stderr. */ + output: () => string; +}; + +/** + * Starts a server on a free port and waits until it answers. + * + * The server binds its port seconds after the port is chosen, and another process can take + * the port in that time. `spawnServer` must make the server stop instead of moving to the + * next port (pass `--strictPort` to Vite): a server that moves can take the port of another + * test that is starting at the same time. When the server reports that its port is taken, + * it is started again on a new port. + */ +export async function startServer( + spawnServer: (port: number) => ReturnType, + timeoutMs: number, + options: Omit = {}, +): Promise { + for (let attempt = 1; ; attempt++) { + const port = await getFreePort(); + const child = spawnServer(port); + const output = collectServerOutput(child); + const closed = new Promise((resolve) => child.once('close', resolve)); + const base = `http://127.0.0.1:${port}`; + const exited = () => child.exitCode !== null || child.signalCode !== null; + // Vite without `--strictPort` (Astro) keeps running and moves to the next port. + const moved = () => output().includes(`Port ${port} is in use`); + try { + await waitForServer(base, timeoutMs, { + ...options, + isAlive: () => !exited() && !moved(), + diagnostics: output, + }); + return { child, port, base, output }; + } catch (error) { + if (exited()) { + // The last output can arrive after the exit. + await Promise.race([closed, delay(1_000)]); + } + // Vite with `--strictPort`, Node, Bun and Deno exit with one of these messages. + const portTaken = moved() || (exited() && /already in use|EADDRINUSE/.test(output())); + await killChild(child); + if (!portTaken) { + throw error; + } + if (attempt === START_ATTEMPTS) { + throw new Error(`Another process took the server port ${START_ATTEMPTS} times`, { + cause: error, + }); + } + console.warn(`Another process took port ${port}; starting the server on a new port.`); + } + } +} + export async function waitForServer( url: string, timeoutMs: number, - options?: { - expectBody?: string; - rejectBody?: string; - expectStatus?: number; - isAlive?: () => boolean; - requestTimeoutMs?: number; - diagnostics?: () => string; - }, + options?: WaitForServerOptions, ): Promise { - // Release the held port so the child process can bind to it. - // This is called right after spawn(), minimizing the TOCTOU window. - const urlPort = Number(new URL(url).port); - const held = heldServers.get(urlPort); - if (held) { - held.close(); - await once(held, 'close'); - heldServers.delete(urlPort); - } - const effectiveTimeout = timeoutMs * CI_TIMEOUT_SCALE; const start = Date.now(); while (Date.now() - start < effectiveTimeout) { if (options?.isAlive && !options.isAlive()) { - throw new Error( - `Server process exited while waiting for ${url}\n${options.diagnostics?.() ?? ''}`, - ); + throw new Error(`Server stopped while waiting for ${url}\n${options.diagnostics?.() ?? ''}`); } try { const remainingMs = effectiveTimeout - (Date.now() - start); From b40f70a94b66dbc199bb87c67db45191fa9c7c6e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 19:48:31 +0800 Subject: [PATCH 2/2] Count server answers only after the server prints its own URL Another HTTP server that took the chosen port could answer before the started server bound it. startServer then returned at once, and the test ran against that other server while ours exited with "already in use". Answers now count only after the server prints its URL with the chosen port (Vite and Astro "Local: http://127.0.0.1:/", the Node, Bun and Deno servers "Listening on http://...:"). A server that exits on the taken port is still started again on a new one. Nuxt prints no URL under Vitest, so its test opts out and keeps relying on its page body check. Co-Authored-By: Claude Opus 5.5 (1M context) --- TESTING.md | 3 ++ packages/void/test/integration/nuxt.test.ts | 5 +-- .../test/unit/integration-helpers.test.ts | 32 +++++++++++++++++-- test/integration-helpers.ts | 31 +++++++++++++++++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/TESTING.md b/TESTING.md index 3e6f99a7f..d1b2814ae 100644 --- a/TESTING.md +++ b/TESTING.md @@ -265,6 +265,9 @@ Start integration servers with `startServer` from `test/integration-helpers.ts`, `--strictPort` to Vite. A server binds its port seconds after the port is chosen, so another process can take it first. Vite without `--strictPort` then moves to the next port, which can be another test's port. With it, the server exits and `startServer` starts it again on a new port. +Another process that took the port can answer on it too, so answers count only after the server +prints its own URL with the port (`Local: http://…:/` or `Listening on http://…:`). +For a server that prints no URL, pass `printsUrl: false` and an `expectBody` only that app serves. Windows integration tests also use `maxConcurrency: 2`. `maxWorkers` caps files, not concurrent tests inside each file: two CI workers with Vitest's default concurrency of five can open ten diff --git a/packages/void/test/integration/nuxt.test.ts b/packages/void/test/integration/nuxt.test.ts index ff0b541ef..5a41d8cb5 100644 --- a/packages/void/test/integration/nuxt.test.ts +++ b/packages/void/test/integration/nuxt.test.ts @@ -28,7 +28,8 @@ describe( removeWranglerConfig(pg); // Nuxt has no strict port option: if its port is taken, it moves to a random port - // without saying so, and this start times out instead of retrying. + // without saying so, and this start times out instead of retrying. It also prints no + // URL under Vitest, so only the page body shows that the answer comes from this app. const server = await startServer( (port) => { child = spawn( @@ -55,7 +56,7 @@ describe( return child; }, 30_000, - { expectStatus: 200, expectBody: 'Nuxt + Void' }, + { expectStatus: 200, expectBody: 'Nuxt + Void', printsUrl: false }, ); DEV_BASE = server.base; serverOutput = server.output; diff --git a/packages/void/test/unit/integration-helpers.test.ts b/packages/void/test/unit/integration-helpers.test.ts index c56aa3934..2eb1e6207 100644 --- a/packages/void/test/unit/integration-helpers.test.ts +++ b/packages/void/test/unit/integration-helpers.test.ts @@ -11,6 +11,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; +import { createServer as createHttpServer } from 'node:http'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { isAbsolute, join, relative } from 'node:path'; @@ -22,9 +23,13 @@ import { startServer, } from '../../../../test/integration-helpers.ts'; -// Answers HTTP on 127.0.0.1: after a short delay, like a dev server that is still starting. +// Answers HTTP on 127.0.0.1: after a short delay, like a dev server that is still starting, +// and prints its URL once it listens. const SERVER = `setTimeout(() => { - require('node:http').createServer((_, res) => res.end('ok')).listen(Number(process.argv[1]), '127.0.0.1'); + const port = Number(process.argv[1]); + require('node:http') + .createServer((_, res) => res.end('ok')) + .listen(port, '127.0.0.1', () => console.log('Listening on http://127.0.0.1:' + port)); }, 200);`; describe('startServer', () => { @@ -52,6 +57,29 @@ describe('startServer', () => { } }); + it('does not accept another server that answers on the port before this one listens', async () => { + const ports: Array = []; + let other: Server | undefined; + try { + const server = await startServer((port) => { + ports.push(port); + if (!other) { + other = createHttpServer((_, res) => res.end('other')).listen(port, '127.0.0.1'); + } + return spawn(process.execPath, ['-e', SERVER, String(port)], { stdio: 'pipe' }); + }, 10_000); + try { + expect(ports).toHaveLength(2); + expect(server.port).toBe(ports[1]); + expect(await (await fetch(server.base)).text()).toBe('ok'); + } finally { + await killChild(server.child); + } + } finally { + other?.close(); + } + }); + it('starts the server again when it moves to another port instead of exiting', async () => { const ports: Array = []; const server = await startServer((port) => { diff --git a/test/integration-helpers.ts b/test/integration-helpers.ts index b8b2df786..439a31512 100644 --- a/test/integration-helpers.ts +++ b/test/integration-helpers.ts @@ -15,6 +15,7 @@ import { import { createServer, type AddressInfo } from 'node:net'; import { basename, join, relative } from 'node:path/posix'; import { setTimeout as delay } from 'node:timers/promises'; +import { stripVTControlCharacters } from 'node:util'; /** * Timeout multiplier for Windows CI runners, which are 2–3× slower @@ -150,10 +151,23 @@ export type WaitForServerOptions = { rejectBody?: string; expectStatus?: number; isAlive?: () => boolean; + /** Answers on the URL count only once this returns true. */ + isReady?: () => boolean; requestTimeoutMs?: number; diagnostics?: () => string; }; +export type StartServerOptions = Omit< + WaitForServerOptions, + 'isAlive' | 'isReady' | 'diagnostics' +> & { + /** + * Pass `false` for a server that does not print its URL (Nuxt under Vitest). Answers then count + * as soon as they match, so also pass an `expectBody` that only this app serves. + */ + printsUrl?: boolean; +}; + export type StartedServer = { child: ReturnType; port: number; @@ -171,11 +185,16 @@ export type StartedServer = { * next port (pass `--strictPort` to Vite): a server that moves can take the port of another * test that is starting at the same time. When the server reports that its port is taken, * it is started again on a new port. + * + * Another process that takes the port can also answer on it. So answers count only after the + * server prints its own URL with the port, which it does once it listens: Vite and Astro print + * `Local: http://127.0.0.1:/`, and the Node, Bun and Deno servers print + * `Listening on http://…:`. */ export async function startServer( spawnServer: (port: number) => ReturnType, timeoutMs: number, - options: Omit = {}, + { printsUrl = true, ...options }: StartServerOptions = {}, ): Promise { for (let attempt = 1; ; attempt++) { const port = await getFreePort(); @@ -186,10 +205,16 @@ export async function startServer( const exited = () => child.exitCode !== null || child.signalCode !== null; // Vite without `--strictPort` (Astro) keeps running and moves to the next port. const moved = () => output().includes(`Port ${port} is in use`); + const listeningLine = new RegExp( + String.raw`(?:Local:?|Listening on)\s+https?://[^\s/]+:${port}(?!\d)`, + ); + let listening = false; try { await waitForServer(base, timeoutMs, { ...options, isAlive: () => !exited() && !moved(), + isReady: () => + !printsUrl || (listening ||= listeningLine.test(stripVTControlCharacters(output()))), diagnostics: output, }); return { child, port, base, output }; @@ -225,6 +250,10 @@ export async function waitForServer( if (options?.isAlive && !options.isAlive()) { throw new Error(`Server stopped while waiting for ${url}\n${options.diagnostics?.() ?? ''}`); } + if (options?.isReady && !options.isReady()) { + await delay(200); + continue; + } try { const remainingMs = effectiveTimeout - (Date.now() - start); const response = await fetch(url, {