Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 7 additions & 6 deletions packages/react/test/integration/react-pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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');
Expand All @@ -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'));
Expand All @@ -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');
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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')");
Expand Down
19 changes: 7 additions & 12 deletions packages/solid/test/integration/solid-pages-playground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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');
Expand All @@ -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
Expand All @@ -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');
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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')");
Expand Down
13 changes: 2 additions & 11 deletions packages/svelte/test/integration/svelte-pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
});
Expand Down Expand Up @@ -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) => ({
Expand Down
38 changes: 38 additions & 0 deletions packages/void/test/integration/browser-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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('<main></main>');
await expect(waitForIslandsHydrated(page, 1, 500)).rejects.toThrow('Timeout');

await page.setContent('<div data-island="counter" data-hydrated="true"></div>');
await expect(waitForIslandsHydrated(page, 2, 500)).rejects.toThrow('Timeout');

await page.setContent(
'<div data-island="counter" data-hydrated="true"></div><div data-island="form"></div>',
);
// 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();
}
});
33 changes: 24 additions & 9 deletions packages/void/test/integration/nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..', '..', '..', '..');
Expand Down Expand Up @@ -38,7 +39,7 @@ describe(
'playground/nuxt dev',
() => {
let child: ReturnType<typeof spawn>;
let stderr = '';
let serverOutput = '';

beforeAll(
async () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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();
}
Expand Down
32 changes: 24 additions & 8 deletions packages/void/test/integration/sveltekit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..', '..', '..', '..');
Expand Down Expand Up @@ -33,7 +34,7 @@ describe('playground/sveltekit build', () => {

describe('playground/sveltekit dev', () => {
let child: ReturnType<typeof spawn>;
let stderr = '';
let serverOutput = '';

beforeAll(async () => {
const pg = join(ROOT, PLAYGROUND);
Expand Down Expand Up @@ -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);
}
});

Expand All @@ -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(() => {
Expand All @@ -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();
}
Expand Down
Loading