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 @@ + + 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