diff --git a/TESTING.md b/TESTING.md
index 4501b0eb0..5ab99a345 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -18,6 +18,39 @@ vpr test:all
`vpr test:all` formats, lints, builds, typechecks, and tests the merged monorepo. Investigate slow
or hanging tests when they occur instead of routinely increasing timeouts.
+## Browser readiness and Windows CI
+
+Use `waitForIslandsHydrated` from `test/browser-helpers.ts` before asserting island state or
+interacting with islands. Pass the expected minimum count when checking a multi-island fixture.
+Each island imports and hydrates independently, so a selector matching one hydrated island does
+not establish that its siblings are ready. The helper rejects empty or partially hydrated pages;
+`packages/void/test/integration/browser-helpers.test.ts` covers these cases.
+
+For SPA navigation, wait for a client readiness signal before setting the document marker and
+clicking. The Nuxt and SvelteKit playgrounds expose `h1[data-hydrated="true"]` after client startup.
+`networkidle` and server-rendered HTML do not establish router readiness. Keep the document-marker
+assertion so a full reload still fails the test. These suites attach recent browser console,
+navigation, failed-request, HMR, and dev-server output to failures.
+
+Give real subprocess tests a measured, suite-local timeout. The Drizzle drift suite can launch
+three processes per case and uses 30 seconds on Windows and 10 seconds elsewhere. Ordinary unit
+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.
+
+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
+files or changing Linux/macOS concurrency. Do not retry browser navigation to hide
+`ERR_NO_BUFFER_SPACE`; it reports a Windows socket resource failure, not a readiness timeout.
+
+The Windows setup hook prints one resource snapshot per integration file at the first failed
+test, before suite teardown. It records memory, the largest processes, dynamic TCP port ranges,
+and connection counts by state/PID. Collection is read-only, limited to ten seconds and 64 KiB,
+and retains partial output if it fails. Concurrent failures share the snapshot. It does not log
+environment variables, process command lines, or remote addresses, and does not replace the
+original test failure. Use this evidence to distinguish port, queue, and memory pressure rather
+than assuming every socket error is port exhaustion.
+
## Native Cloudflare live E2E tests
Live tests are opt-in. They provision or update real resources and can write data, enqueue messages,
diff --git a/packages/react/test/integration/react-pages-client.test.ts b/packages/react/test/integration/react-pages-client.test.ts
index 3fe321a78..0ba9ac842 100644
--- a/packages/react/test/integration/react-pages-client.test.ts
+++ b/packages/react/test/integration/react-pages-client.test.ts
@@ -4,6 +4,7 @@ import spawn from 'cross-spawn';
import { join } from 'pathslash';
import { chromium, 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';
declare global {
@@ -914,7 +915,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page, 2);
const islands = await page.$$eval('[data-island]', (els) =>
els.map((el) => ({
@@ -936,7 +937,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
const countBefore = await page.textContent('[data-testid="count"]');
expect(countBefore).toBe('Count: 0');
@@ -957,7 +958,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
await page.click('button:has-text("Add Post")');
await page.waitForFunction(() => document.body.textContent?.includes('Title is required'));
@@ -974,7 +975,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
await page.fill('input:near(:text("Title:"))', 'New Post');
await page.fill('textarea:near(:text("Body:"))', 'Some content');
@@ -1011,7 +1012,7 @@ function registerIslandTests(getBase: () => string) {
expect(marker).toBeUndefined();
// Islands should be hydrated
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
} finally {
await page.context().close();
}
@@ -1398,7 +1399,7 @@ describe('react-pages client (dev)', () => {
try {
await page.goto(`${DEV_BASE}/blog`);
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
// Interact to establish client state — count becomes 1
await page.click("button:has-text('Increment')");
diff --git a/packages/solid/test/integration/solid-pages-playground.test.ts b/packages/solid/test/integration/solid-pages-playground.test.ts
index 845d34737..a3c0c0a70 100644
--- a/packages/solid/test/integration/solid-pages-playground.test.ts
+++ b/packages/solid/test/integration/solid-pages-playground.test.ts
@@ -4,6 +4,7 @@ import spawn from 'cross-spawn';
import { join } from 'pathslash';
import { chromium, type Browser, type Page } from 'playwright';
import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
+import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts';
import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts';
declare global {
@@ -485,13 +486,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForFunction(() => {
- const islands = [...document.querySelectorAll('[data-island]')];
- return (
- islands.length >= 2 &&
- islands.every((island) => island.getAttribute('data-hydrated') === 'true')
- );
- });
+ await waitForIslandsHydrated(page, 2);
const islands = await page.$$eval('[data-island]', (els) =>
els.map((el) => ({
@@ -513,7 +508,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
const countBefore = await page.textContent('[data-testid="count"]');
expect(countBefore).toBe('Count: 0');
@@ -534,7 +529,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
await page.click('button:has-text("Add Post")');
// The first action can include a cold worker compile under full-suite load. Keep the normal
@@ -553,7 +548,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
await page.fill('input:near(:text("Title:"))', 'New Post');
await page.fill('textarea:near(:text("Body:"))', 'Some content');
@@ -590,7 +585,7 @@ function registerIslandTests(getBase: () => string) {
expect(marker).toBeUndefined();
// Islands should be hydrated
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
} finally {
await page.context().close();
}
@@ -884,7 +879,7 @@ describe('solid-pages client (dev)', () => {
try {
await page.goto(`${DEV_BASE}/blog`);
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
// Interact to establish client state — count becomes 1
await page.click("button:has-text('Increment')");
diff --git a/packages/svelte/test/integration/svelte-pages-client.test.ts b/packages/svelte/test/integration/svelte-pages-client.test.ts
index 5cb4c01d1..dfe06e350 100644
--- a/packages/svelte/test/integration/svelte-pages-client.test.ts
+++ b/packages/svelte/test/integration/svelte-pages-client.test.ts
@@ -4,6 +4,7 @@ import spawn from 'cross-spawn';
import { join } from 'pathslash';
import { chromium, 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';
declare global {
@@ -26,16 +27,6 @@ let PREVIEW_BASE: string;
let browser: Browser;
-async function waitForIslandsHydrated(page: Page) {
- await page.waitForFunction(() => {
- const islands = Array.from(document.querySelectorAll('[data-island]'));
- return (
- islands.length > 0 &&
- islands.every((island) => island.getAttribute('data-hydrated') === 'true')
- );
- });
-}
-
beforeAll(async () => {
browser = await chromium.launch();
});
@@ -461,7 +452,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await waitForIslandsHydrated(page);
+ await waitForIslandsHydrated(page, 2);
const islands = await page.$$eval('[data-island]', (els) =>
els.map((el) => ({
diff --git a/packages/void/test/integration/browser-helpers.test.ts b/packages/void/test/integration/browser-helpers.test.ts
new file mode 100644
index 000000000..18626fefa
--- /dev/null
+++ b/packages/void/test/integration/browser-helpers.test.ts
@@ -0,0 +1,38 @@
+import { chromium, type Browser } from 'playwright';
+import { afterAll, beforeAll, expect, it } from 'vite-plus/test';
+import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts';
+
+let browser: Browser;
+
+beforeAll(async () => {
+ browser = await chromium.launch();
+});
+
+afterAll(async () => {
+ await browser?.close();
+});
+
+it('waits for the expected islands and every independent hydration', async () => {
+ const page = await browser.newPage();
+ try {
+ // Empty SSR output must not make an all-elements check pass vacuously.
+ await page.setContent('');
+ await expect(waitForIslandsHydrated(page, 1, 500)).rejects.toThrow('Timeout');
+
+ await page.setContent('
');
+ await expect(waitForIslandsHydrated(page, 2, 500)).rejects.toThrow('Timeout');
+
+ await page.setContent(
+ '',
+ );
+ // The old any-island selector returned here while the form still had no event handlers.
+ await expect(waitForIslandsHydrated(page, 2, 500)).rejects.toThrow('Timeout');
+
+ await page.locator('[data-island="form"]').evaluate((el) => {
+ el.setAttribute('data-hydrated', 'true');
+ });
+ await waitForIslandsHydrated(page, 2);
+ } finally {
+ await page.close();
+ }
+});
diff --git a/packages/void/test/integration/nuxt.test.ts b/packages/void/test/integration/nuxt.test.ts
index a51bd158f..32e9e6071 100644
--- a/packages/void/test/integration/nuxt.test.ts
+++ b/packages/void/test/integration/nuxt.test.ts
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs';
import spawn from 'cross-spawn';
import { join } from 'pathslash';
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';
const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
@@ -38,7 +39,7 @@ describe(
'playground/nuxt dev',
() => {
let child: ReturnType;
- let stderr = '';
+ let serverOutput = '';
beforeAll(
async () => {
@@ -72,14 +73,23 @@ describe(
},
);
- child.stderr!.on('data', (chunk: Buffer) => {
- stderr += String(chunk);
- });
-
- const serverTimeout = process.platform === 'win32' ? 60_000 : 30_000;
- await waitForServer(DEV_BASE, serverTimeout);
+ 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 });
+ }
},
- process.platform === 'win32' ? 75_000 : 45_000,
+ process.platform === 'win32' ? 120_000 : 45_000,
);
afterAll(async () => {
@@ -110,10 +120,13 @@ describe(
it('navigates to smoke route via SPA from home page', async () => {
const { chromium } = await import('playwright');
const browser = await chromium.launch();
+ let diagnostics = () => '';
try {
const page = await browser.newPage();
+ diagnostics = collectBrowserDiagnostics(page);
await page.goto(DEV_BASE);
- await page.waitForLoadState('networkidle');
+ // SSR and network inactivity do not mean Nuxt has installed its router yet.
+ await page.waitForSelector('h1[data-hydrated="true"]');
// Mark the document to detect full page reloads
await page.evaluate(() => {
@@ -134,6 +147,8 @@ describe(
const kvValue = await page.textContent('[data-testid="kv-value"]');
expect(d1User).toBe('Alice');
expect(kvValue).toBe('ok');
+ } catch (error) {
+ throw new Error(`${diagnostics()}\nDev server:\n${serverOutput}`, { cause: error });
} finally {
await browser.close();
}
diff --git a/packages/void/test/integration/sveltekit.test.ts b/packages/void/test/integration/sveltekit.test.ts
index aa194d3ce..9b86dca00 100644
--- a/packages/void/test/integration/sveltekit.test.ts
+++ b/packages/void/test/integration/sveltekit.test.ts
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import spawn from 'cross-spawn';
import { join } from 'pathslash';
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';
const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
@@ -33,7 +34,7 @@ describe('playground/sveltekit build', () => {
describe('playground/sveltekit dev', () => {
let child: ReturnType;
- let stderr = '';
+ let serverOutput = '';
beforeAll(async () => {
const pg = join(ROOT, PLAYGROUND);
@@ -66,19 +67,29 @@ describe('playground/sveltekit dev', () => {
},
);
- child.stderr!.on('data', (chunk: Buffer) => {
- stderr += String(chunk);
- });
+ const recordOutput = (chunk: Buffer) => {
+ serverOutput = (serverOutput + String(chunk)).slice(-20_000);
+ };
+ child.stdout!.on('data', recordOutput);
+ child.stderr!.on('data', recordOutput);
- await waitForServer(DEV_BASE, 20_000);
+ 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 });
+ }
});
afterAll(async () => {
if (child) {
await killChild(child);
}
- if (stderr.includes('error when starting dev server')) {
- throw new Error(stderr);
+ if (serverOutput.includes('error when starting dev server')) {
+ throw new Error(serverOutput);
}
});
@@ -104,10 +115,13 @@ describe('playground/sveltekit dev', () => {
it('navigates to smoke route via SPA from home page', async () => {
const { chromium } = await import('playwright');
const browser = await chromium.launch();
+ let diagnostics = () => '';
try {
const page = await browser.newPage();
+ diagnostics = collectBrowserDiagnostics(page);
await page.goto(DEV_BASE);
- await page.waitForLoadState('networkidle');
+ // Wait for client mounting before clicking the server-rendered link.
+ await page.waitForSelector('h1[data-hydrated="true"]');
// Mark the document to detect full page reloads
await page.evaluate(() => {
@@ -128,6 +142,8 @@ describe('playground/sveltekit dev', () => {
const kvValue = await page.textContent('[data-testid="kv-value"]');
expect(d1User).toBe('Alice');
expect(kvValue).toBe('ok');
+ } catch (error) {
+ throw new Error(`${diagnostics()}\nDev server:\n${serverOutput}`, { cause: error });
} finally {
await browser.close();
}
diff --git a/packages/void/test/unit/deploy-drift.test.ts b/packages/void/test/unit/deploy-drift.test.ts
index b4e009121..935b5f5f1 100644
--- a/packages/void/test/unit/deploy-drift.test.ts
+++ b/packages/void/test/unit/deploy-drift.test.ts
@@ -38,7 +38,10 @@ const WRANGLER_AUTH_VARS = [
'CLOUDFLARE_ACCESS_CLIENT_SECRET',
];
-describe('hasDeploySchemaDrift', () => {
+// These cases run real Drizzle processes (up to three per test). Windows CI can take
+// over five seconds even when every operation succeeds; keep that budget local to this suite.
+const driftTestTimeout = process.platform === 'win32' ? 30_000 : 10_000;
+describe('hasDeploySchemaDrift', { timeout: driftTestTimeout }, () => {
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop()!, { recursive: true, force: true });
diff --git a/packages/void/test/unit/windows-resource-diagnostics.test.ts b/packages/void/test/unit/windows-resource-diagnostics.test.ts
new file mode 100644
index 000000000..259197bed
--- /dev/null
+++ b/packages/void/test/unit/windows-resource-diagnostics.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, expect, it, vi } from 'vite-plus/test';
+import { createWindowsResourceReporter } from '../../../../test/windows-resource-diagnostics.ts';
+
+const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
+vi.mock('node:child_process', () => ({ execFile: execFileMock }));
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ execFileMock.mockReset();
+});
+
+it('shares one bounded snapshot across concurrent failures', async () => {
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const report = createWindowsResourceReporter();
+ const first = report();
+ const second = report();
+
+ expect(second).toBe(first);
+ expect(execFileMock).toHaveBeenCalledOnce();
+ const [command, args, options, finish] = execFileMock.mock.calls[0]!;
+ expect(command).toBe('powershell.exe');
+ expect(args).toEqual([
+ '-NoLogo',
+ '-NoProfile',
+ '-NonInteractive',
+ '-File',
+ expect.stringMatching(/windows-resource-diagnostics\.ps1$/),
+ ]);
+ expect(options).toEqual({
+ encoding: 'utf8',
+ timeout: 10_000,
+ maxBuffer: 64 * 1024,
+ windowsHide: true,
+ });
+ finish(null, 'TCP connections: 12', '');
+ await Promise.all([first, second, report()]);
+ expect(execFileMock).toHaveBeenCalledOnce();
+ expect(log).toHaveBeenCalledExactlyOnceWith('Windows resource snapshot:\nTCP connections: 12');
+});
+
+it('retains partial output without rejecting when collection times out', async () => {
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {});
+ execFileMock.mockImplementation((_command, _args, _options, finish) => {
+ finish(new Error('timed out'), 'Memory (KiB): partial snapshot', 'TCP query interrupted');
+ });
+
+ await expect(createWindowsResourceReporter()()).resolves.toBeUndefined();
+ expect(log).toHaveBeenCalledWith(
+ 'Windows resource snapshot:\nMemory (KiB): partial snapshot\nTCP query interrupted\nSnapshot incomplete: timed out',
+ );
+});
+
+it('does not turn a process startup failure into another test failure', async () => {
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {});
+ execFileMock.mockImplementation(() => {
+ throw new Error('unable to spawn');
+ });
+
+ await expect(createWindowsResourceReporter()()).resolves.toBeUndefined();
+ expect(log).toHaveBeenCalledWith(
+ 'Windows resource snapshot:\nSnapshot incomplete: unable to spawn',
+ );
+});
diff --git a/packages/vue/test/integration/vue-pages-client.test.ts b/packages/vue/test/integration/vue-pages-client.test.ts
index 67a10ad1b..ff93193b1 100644
--- a/packages/vue/test/integration/vue-pages-client.test.ts
+++ b/packages/vue/test/integration/vue-pages-client.test.ts
@@ -4,6 +4,7 @@ import spawn from 'cross-spawn';
import { join } from 'pathslash';
import { chromium, 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';
declare global {
@@ -485,7 +486,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page, 2);
const islands = await page.$$eval('[data-island]', (els) =>
els.map((el) => ({
@@ -507,7 +508,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
const countBefore = await page.textContent('[data-testid="count"]');
expect(countBefore).toBe('Count: 0');
@@ -528,7 +529,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
await page.click('button:has-text("Add Post")');
await page.waitForFunction(() => document.body.textContent?.includes('Title is required'));
@@ -545,7 +546,7 @@ function registerIslandTests(getBase: () => string) {
const page = await newPage();
try {
await page.goto(BASE + '/blog');
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
await page.fill('input:near(:text("Title:"))', 'New Post');
await page.fill('textarea:near(:text("Body:"))', 'Some content');
@@ -582,7 +583,7 @@ function registerIslandTests(getBase: () => string) {
expect(marker).toBeUndefined();
// Islands should be hydrated
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
} finally {
await page.context().close();
}
@@ -947,7 +948,7 @@ describe('vue-pages client (dev)', () => {
try {
await prepareForViteHmr(page);
await page.goto(`${DEV_BASE}/blog`);
- await page.waitForSelector('[data-island][data-hydrated="true"]');
+ await waitForIslandsHydrated(page);
await waitForViteHmr(page);
// Interact to establish client state — count becomes 1
diff --git a/playground/nuxt/pages/index.vue b/playground/nuxt/pages/index.vue
index 6dad9375a..5df3f9ae4 100644
--- a/playground/nuxt/pages/index.vue
+++ b/playground/nuxt/pages/index.vue
@@ -1,5 +1,12 @@
+
+
- Nuxt + Void
+ Nuxt + Void
This playground demonstrates meta-framework integration.
Smoke Test
diff --git a/playground/sveltekit/src/routes/+page.svelte b/playground/sveltekit/src/routes/+page.svelte
index d238a805b..0e00c0853 100644
--- a/playground/sveltekit/src/routes/+page.svelte
+++ b/playground/sveltekit/src/routes/+page.svelte
@@ -1,3 +1,12 @@
-SvelteKit + Void
+
+
+SvelteKit + Void
This playground demonstrates meta-framework integration.
Smoke Test
diff --git a/test/browser-helpers.ts b/test/browser-helpers.ts
new file mode 100644
index 000000000..a86c00a7f
--- /dev/null
+++ b/test/browser-helpers.ts
@@ -0,0 +1,52 @@
+import type { Page } from 'playwright';
+
+/** Each island loads independently; one hydrated island says nothing about its siblings. */
+export async function waitForIslandsHydrated(
+ page: Page,
+ minimumCount = 1,
+ timeout?: number,
+): Promise {
+ await page.waitForFunction(
+ (minimum) => {
+ const islands = Array.from(document.querySelectorAll('[data-island]'));
+ return (
+ islands.length >= minimum &&
+ islands.every((island) => island.getAttribute('data-hydrated') === 'true')
+ );
+ },
+ minimumCount,
+ { timeout },
+ );
+}
+
+/** Retain the events that distinguish hydration errors, native navigation, and Vite reloads. */
+export function collectBrowserDiagnostics(page: Page): () => string {
+ const events: Array = [];
+ const record = (message: string) => {
+ events.push(message);
+ if (events.length > 100) {
+ events.shift();
+ }
+ };
+
+ page.on('console', (message) => record(`console.${message.type()}: ${message.text()}`));
+ page.on('pageerror', (error) => record(`pageerror: ${error.message}`));
+ page.on('requestfailed', (request) =>
+ record(`requestfailed: ${request.url()} ${request.failure()?.errorText}`),
+ );
+ page.on('request', (request) => {
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
+ record(`document: ${request.method()} ${request.url()}`);
+ }
+ });
+ page.on('websocket', (socket) => {
+ socket.on('framereceived', ({ payload }) => {
+ const message = String(payload);
+ if (message.includes('full-reload') || message.includes('"type":"error"')) {
+ record(`websocket: ${message}`);
+ }
+ });
+ });
+
+ return () => `Browser events:\n${events.join('\n')}`;
+}
diff --git a/test/windows-integration-setup.ts b/test/windows-integration-setup.ts
new file mode 100644
index 000000000..36ff144cc
--- /dev/null
+++ b/test/windows-integration-setup.ts
@@ -0,0 +1,9 @@
+import { beforeEach } from 'vite-plus/test';
+import { createWindowsResourceReporter } from './windows-resource-diagnostics.ts';
+
+// Capture at the first failed test, before suite teardown removes browsers and dev servers.
+// Concurrent failures share one bounded snapshot per file, rather than starting more processes.
+const reportResources = createWindowsResourceReporter();
+beforeEach(({ onTestFailed }) => {
+ onTestFailed(reportResources);
+});
diff --git a/test/windows-resource-diagnostics.ps1 b/test/windows-resource-diagnostics.ps1
new file mode 100644
index 000000000..f0371d2aa
--- /dev/null
+++ b/test/windows-resource-diagnostics.ps1
@@ -0,0 +1,44 @@
+# Read-only snapshot. Do not print environment variables, command lines, or remote addresses.
+# The caller enforces a ten-second deadline and retains partial output if collection times out.
+$ErrorActionPreference = 'Stop'
+Write-Output "Captured at $([DateTime]::UtcNow.ToString('o'))"
+
+try {
+ Write-Output 'Memory (KiB):'
+ Get-CimInstance Win32_OperatingSystem -OperationTimeoutSec 3 |
+ Select-Object TotalVisibleMemorySize, FreePhysicalMemory, TotalVirtualMemorySize, FreeVirtualMemory |
+ Format-List | Out-String | Write-Output
+} catch {
+ Write-Output "Memory snapshot unavailable: $_"
+}
+
+try {
+ Write-Output 'Top processes by working set (bytes):'
+ Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 15 |
+ Select-Object Id, ProcessName, Handles, WorkingSet64, PrivateMemorySize64 |
+ Format-Table -AutoSize | Out-String -Width 200 | Write-Output
+} catch {
+ Write-Output "Process snapshot unavailable: $_"
+}
+
+try {
+ # Reports configuration only; never change the runner's TCP settings.
+ Write-Output 'Dynamic TCP port ranges:'
+ netsh int ipv4 show dynamicport tcp
+ netsh int ipv6 show dynamicport tcp
+} catch {
+ Write-Output "Port range snapshot unavailable: $_"
+}
+
+try {
+ $connections = @(Get-NetTCPConnection)
+ Write-Output "TCP connections: $($connections.Count)"
+ $connections | Group-Object State | Sort-Object Count -Descending |
+ Select-Object Count, Name | Format-Table -AutoSize | Out-String | Write-Output
+ Write-Output 'Top TCP owners (PID, state):'
+ $connections | Group-Object OwningProcess, State | Sort-Object Count -Descending |
+ Select-Object -First 15 Count, Name |
+ Format-Table -AutoSize | Out-String | Write-Output
+} catch {
+ Write-Output "TCP snapshot unavailable: $_"
+}
diff --git a/test/windows-resource-diagnostics.ts b/test/windows-resource-diagnostics.ts
new file mode 100644
index 000000000..fb359c263
--- /dev/null
+++ b/test/windows-resource-diagnostics.ts
@@ -0,0 +1,42 @@
+import { execFile } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+/** Best-effort, once-per-file diagnostics: never replace the original test failure. */
+export function createWindowsResourceReporter(): () => Promise {
+ let snapshot: Promise | undefined;
+ return () => {
+ snapshot ??= new Promise((resolve) => {
+ const finish = (error: Error | null, stdout = '', stderr = '') => {
+ resolve(
+ [
+ 'Windows resource snapshot:',
+ stdout,
+ stderr,
+ error ? `Snapshot incomplete: ${error.message}` : '',
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ );
+ };
+ try {
+ execFile(
+ 'powershell.exe',
+ [
+ '-NoLogo',
+ '-NoProfile',
+ '-NonInteractive',
+ '-File',
+ fileURLToPath(new URL('./windows-resource-diagnostics.ps1', import.meta.url)),
+ ],
+ { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024, windowsHide: true },
+ finish,
+ );
+ } catch (error) {
+ finish(error instanceof Error ? error : new Error(String(error)));
+ }
+ }).then((output) => {
+ console.error(output);
+ });
+ return snapshot;
+ };
+}
diff --git a/vite.config.ts b/vite.config.ts
index 7f63a0c2a..fc14c2548 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -378,6 +378,12 @@ export default defineConfig({
// schedule the longest files first and fill idle slots across package boundaries.
name: 'parallel-integration',
sequence: { groupOrder: 2 },
+ // maxWorkers limits files, not describe.concurrent tests. On Windows CI, two files
+ // at Vitest's default concurrency open up to ten isolated browser contexts at once.
+ // Reduce socket pressure while keeping file-level parallelism and other OSes unchanged.
+ ...(process.platform === 'win32'
+ ? { maxConcurrency: 2, setupFiles: ['test/windows-integration-setup.ts'] }
+ : {}),
testTimeout,
hookTimeout,
// Kept alongside the e2e glob below so a newly added e2e file gets its credentials