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
18 changes: 17 additions & 1 deletion .github/scripts/packed-consumer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,22 @@ try {
[join(fixture, 'node_modules/typescript/bin/tsc'), '--noEmit'],
fixture,
);
if (useVitePlus) {
// Vitest loads the app's Vite config, Void's plugin included, and runs the tests in
// Node.js. The test from docs/guide/testing.md replaces the KV module it uses.
mkdirSync(join(fixture, 'src'), { recursive: true });
writeFileSync(
join(fixture, 'src/visits.ts'),
`import { kv } from 'void/kv';\nexport async function countVisit(): Promise<number> {\n const visits = ((await kv.get<number>('visits')) ?? 0) + 1;\n await kv.put('visits', visits);\n return visits;\n}\n`,
);
writeFileSync(
join(fixture, 'src/visits.test.ts'),
`import { expect, it, vi } from 'vite-plus/test';\nimport { loader } from '../pages/index.server';\nimport { countVisit } from './visits';\nvi.mock('void/kv', () => {\n const values = new Map<string, unknown>();\n return { kv: { get: async (key: string) => values.get(key) ?? null, put: async (key: string, value: unknown) => { values.set(key, value); } } };\n});\nit('counts visits', async () => {\n await countVisit();\n expect(await countVisit()).toBe(2);\n});\nit('imports a page loader', () => {\n expect(loader).toBeTypeOf('function');\n});\n`,
);
await run(process.execPath, [vite, 'test', 'run'], fixture);
rmSync(join(fixture, 'src/visits.ts'));
rmSync(join(fixture, 'src/visits.test.ts'));
}
// The installed package must find and verify every bundled runtime artifact.
await run(
process.execPath,
Expand Down Expand Up @@ -496,7 +512,7 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
}
}
console.log(
`Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+' : 'public Vite'} build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`,
`Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`,
);
} catch (error) {
failures.push({ fixture: label, error: String(error.stack ?? error) });
Expand Down
3 changes: 2 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ node --test --test-timeout=10000 .github/scripts/packed-*.test.mjs

`VOID_PACKED_PM=npm|pnpm` and `VOID_PACKED_FRAMEWORK=react|vue|svelte|solid` select a focused
local run. `VOID_PACKED_TOOLCHAIN=vite-plus` verifies Vite+ initialization, the workspace's
tested Vite+ version, and the generated `vp` build/dev/preview commands. CI checks a React
tested Vite+ version, the generated `vp` build/dev/preview commands, and `vp test` with the
app's Vite config. CI checks a React
app with Vite+ for both package managers alongside the plain Vite coverage.
The interactive initializer is a Node.js script using the system `script` PTY
utility and Bash; it has no Python or native npm dependency.
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export default extendConfig(
icon: 'lucide:rocket',
collapsed: false,
items: [
{ text: 'Testing', link: '/guide/testing' },
{ text: 'Remote Development', link: '/guide/remote-dev' },
{ text: 'Deploying Your App', link: '/guide/deployment' },
{ text: 'Project Collaboration', link: '/guide/project-collaboration' },
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/email/receiving.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Test inbound handlers locally with `createInboundTestHarness` below.
Use `createInboundTestHarness` to capture replies, forwards, and rejections. For shared platform mail, pass the full address including the project prefix, such as `acme+support+abc-123@acme.dev`. For direct Cloudflare mail, set `slug: null` and use `support+abc-123@mail.acme.com`.

```ts
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from 'vite-plus/test';
import { createInboundTestHarness } from 'void/email/testing';
import defaultHandler from './email/_default';
import support from './email/support+[ticket]';
Expand Down Expand Up @@ -138,4 +138,4 @@ describe('email handlers', () => {
});
```

The harness matches handlers in the same order as deployed email routes.
The harness matches handlers in the same order as deployed email routes. [Testing](../testing.md) explains how to run tests and how to test code that uses other bindings.
4 changes: 2 additions & 2 deletions docs/guide/email/sending.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ Remove the deprecated `sendInDev` option to use the local inbox. Deploy the app
Use the test harness to assert on outgoing messages without mocking the binding:

```ts
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from 'vite-plus/test';
import { sendEmail } from 'void/email';
import { createEmailTestHarness } from 'void/email/testing';

Expand All @@ -254,6 +254,6 @@ describe('signup flow', () => {
});
```

The harness intercepts every `sendEmail` call until `dispose()` is called. `clear()` empties the captured list without releasing the sink.
The harness intercepts every `sendEmail` call until `dispose()` is called. `clear()` empties the captured list without releasing the sink. [Testing](../testing.md) explains how to run tests and how to test code that uses other bindings.

To handle incoming messages, see [Receiving Email](./receiving.md).
61 changes: 61 additions & 0 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
outline: deep
---

# Testing

Test your app with [Vitest](https://vitest.dev/). With Vite+, the default toolchain, run it with `vp test`:

```sh
vp test
```

Import test APIs from `vite-plus/test`. With the Vite toolchain, add `vitest` to your dev dependencies, run `vitest`, and import from `vitest` instead.

Vitest reads your `vite.config.ts`, so `void/*` imports, `@schema`, and the other modules Void provides resolve in tests as they do in your app. Tests need no separate configuration.

Keep test files out of the [convention directories](../reference/structure.md) such as `routes/` and `pages/`. Void reads the files there as part of your app, so a test file in `routes/` becomes a route. Put tests in `tests/`, or next to the code they test in `src/`.

## Bindings {#bindings}

Tests run in Node.js, not in the Workers runtime. Void doesn't start your Worker for a test run, so D1, KV, R2, and other bindings are unavailable, and it doesn't apply migrations to your local database. You can import modules that use bindings, but a call that reaches one throws `env: Cloudflare env is unavailable`.

To test code that uses a binding, replace its module with [`vi.mock()`](https://vitest.dev/api/vi#vi-mock):

```ts
// src/visits.ts
import { kv } from 'void/kv';

export async function countVisit(): Promise<number> {
const visits = ((await kv.get<number>('visits')) ?? 0) + 1;
await kv.put('visits', visits);
return visits;
}
```

```ts
// src/visits.test.ts
import { expect, it, vi } from 'vite-plus/test';
import { countVisit } from './visits';

vi.mock('void/kv', () => {
const values = new Map<string, unknown>();
return {
kv: {
get: async (key: string) => values.get(key) ?? null,
put: async (key: string, value: unknown) => {
values.set(key, value);
},
},
};
});

it('counts visits', async () => {
await countVisit();
expect(await countVisit()).toBe(2);
});
```

## Email {#email}

`void/email/testing` captures sent and received email in tests. See [Testing](./email/sending.md#testing) in Sending Email and [Testing inbound handlers](./email/receiving.md#testing-inbound-handlers) in Receiving Email.
1 change: 1 addition & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ production functions and real generated entries rather than copied code.

- **Workers types 5 still declares the Buffer constructor as `any`.** In the mixed Node/Workers type environment, this also affects `node:buffer`'s exported value. CLI code and tests that need Buffer constructor inference or narrowing use `cli/node-buffer.ts`, which retains Node's `BufferConstructor` signatures. Keep `workers-node-globals.typecheck.ts` covering constructor checks and Node Buffer instance methods.
- **Cloudflare browser login needs all terminal streams.** `cf auth login` prints its device confirmation code to stderr, so the auth and deploy runners inherit stdin, stdout, and stderr only for login; credential reads stay captured. Keep the asynchronous platform runner cancellable.
- **Vitest runs an app's tests in a Vite server of its own.** That server loads the app's Vite config, so serve plugins run there too, but Vitest turns off dependency prebundling, which the Cloudflare plugin needs to start the Worker (`module is not defined`, or `depsOptimizer is required in dev mode` with `nodejs_compat`). `isVitestServer()` in `plugin-dev-config.ts` recognizes the server by Vitest's plugin names, in any `--mode`. `cloudflare()` and `migrationPlugin` do not apply there, and the AI setup prints its hint instead of prompting. A server that a test starts through Vite's API keeps them all. Gate new dev-server work that changes a database or asks questions the same way; code generation still runs. `test/integration/vitest-app.test.ts` runs the tests of a Void app and a framework app; check Vitest's plugin names again when updating it.
- **Cloudflare compatibility flags must be merged as a set.** The Cloudflare plugin concatenates callback arrays with the resolved generated config. `mergeBindings` must emit the union once and clear the in-memory fallback, preserving all distinct flags without rewriting authored config. Existing Worker handoff copies production flags into the generated Cloudflare config, so concatenation otherwise causes upload error `10021` (`Compatibility flag specified multiple times`). Keep real-build coverage for `cloudflare` and `worker` overlap.
- **`__VOID_ISR_DEPLOYMENT_ID` must be a compile-time literal.** `router/compile.ts` stringifies `crypto.randomUUID()` once at build time (`export const __VOID_ISR_DEPLOYMENT_ID = "..."`, exactly like `__VOID_BUILD_KEY`) so every isolate of a deployment shares one id. A bare runtime `crypto.randomUUID()` per-isolate in the emitted source would mint a fresh id per isolate, so the edge-cache key (`handleIsr`'s `deploymentId` option) and the KV key (`env.__VOID_ISR_DEPLOYMENT_ID`, also used by the `revalidate()` purge path) would disagree — breaking cache reads and on-demand purge.
- **Self-host ISR requires a user-declared `ISR_CACHE` KV.** There is no `id: 'local'` injection for it: a placeholder id is fine for miniflare-backed dev but breaks a real `wrangler deploy`, which rejects an unknown namespace id. The user creates the namespace and binds it as `ISR_CACHE`; `readIsrCache`/`writeIsrCache` fail open to a live render when it is absent.
Expand Down
1 change: 1 addition & 0 deletions packages/void/skills/void/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Use `void deploy --skip-migrations` when database migrations run separately; it
| SSR and caching | `docs/guide/ssr.md`, `docs/guide/edge/*.md` |
| HTTPS, rewrites, redirects, fallbacks | `docs/guide/edge/rewrites.md`, `docs/guide/edge/redirects.md`, `docs/reference/config.md` |
| Static site generation | `docs/guide/ssg.md` |
| Testing an app with Vitest (`vp test`) | `docs/guide/testing.md` |
| Connected apps and artifact preparation | `docs/guide/platform/administration/connected-apps.md`, `docs/reference/api/deploy.md` |
| Deployment and CI | `docs/guide/deployment.md`, `docs/reference/cli/deploy.md` |
| Install or maintain a company platform | `docs/guide/self-hosted-platform.md`, `docs/reference/cli/platform-installation.md` |
Expand Down
5 changes: 4 additions & 1 deletion packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
isViteBuildOrPreview,
getOrCreateDevTriggerToken,
devTriggerSecretGuardPlugin,
isVitestServer,
selectVoidCloudflareLogin,
} from './plugin-dev-config.ts';
import { devTriggersHintPlugin } from './plugin-dev-triggers-hint.ts';
Expand Down Expand Up @@ -542,7 +543,9 @@ export function voidPlugin(options?: {
);
});
} else if (credentials.status === 'no-project') {
if (process.stdin.isTTY) {
// A test run must not stop for questions, and Vitest's watch mode reads keys from
// the terminal, so Vitest's server gets the hint below.
if (process.stdin.isTTY && !isVitestServer(server.config)) {
// Fire-and-forget interactive setup
afterServerReady(server, () => {
console.log();
Expand Down
23 changes: 21 additions & 2 deletions packages/void/src/plugin-cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type WorkerConfig,
} from './plugin-cloudflare-config.ts';
import { withLocalPreviewBindings } from './plugin-cloudflare-preview.ts';
import { isVitestServer } from './plugin-dev-config.ts';
import { writeFileAtomic } from './shared/atomic-write.ts';
import { CLOUDFLARE_DEPLOY_BUILD_ENV } from './shared/env.ts';

Expand All @@ -28,6 +29,24 @@ function isInside(directory: string, path: string): boolean {
return child !== '..' && !child.startsWith('../') && !isAbsolute(child);
}

/**
* Vitest runs tests in Node.js, in a Vite server of its own that turns off dependency
* prebundling. The Cloudflare plugin needs prebundling to start the Worker, so the Worker would
* fail before any test runs. Leave these plugins out of that server; Void's other plugins still
* resolve `void/*`, `@schema` and the generated modules for tests.
*/
function skipOnVitestServer(plugins: Array<Plugin>): Array<Plugin> {
for (const plugin of plugins) {
const apply = plugin.apply;
plugin.apply = (config, environment) =>
!isVitestServer(config) &&
(typeof apply === 'function'
? apply(config, environment)
: apply === undefined || apply === environment.command);
}
return plugins;
}

/** Keep Void's public configuration and artifact layout while running the v2 plugin. */
export function cloudflare(options: {
configPath?: string;
Expand Down Expand Up @@ -218,7 +237,7 @@ export function cloudflare(options: {
},
},
};
return [
return skipOnVitestServer([
bridge,
directories,
...withLocalPreviewBindings(
Expand All @@ -237,5 +256,5 @@ export function cloudflare(options: {
root,
),
output,
];
]);
}
29 changes: 28 additions & 1 deletion packages/void/src/plugin-dev-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,36 @@ import {
writeSync,
} from 'node:fs';
import { join } from 'pathslash';
import type { Plugin, ViteDevServer } from 'vite';
import type { Plugin, PluginOption, ViteDevServer } from 'vite';
import { voidWarn } from './log.ts';

/**
* Whether Vite is resolving the server that Vitest creates to run tests, whatever its `--mode`.
* Vitest adds its own plugins (`vitest`, `vitest:*`) to that server's config; a server that a
* test starts through Vite's API has none. Vitest runs test files in Node.js, so Void starts no
* Worker there, applies no local migrations and asks no questions. Check these names again when
* updating Vitest: `test/integration/vitest-app.test.ts` runs the tests of Void apps.
*/
export function isVitestServer(config: {
readonly plugins?: ReadonlyArray<PluginOption>;
}): boolean {
const pending = [...(config.plugins ?? [])];
while (pending.length > 0) {
const plugin = pending.pop();
if (Array.isArray(plugin)) {
pending.push(...plugin);
} else if (
plugin &&
'name' in plugin &&
typeof plugin.name === 'string' &&
(plugin.name === 'vitest' || plugin.name.startsWith('vitest:'))
) {
return true;
}
}
return false;
}

/** Run `fn` shortly after Vite prints "ready" so dev messages stay visible. */
export function afterServerReady(server: ViteDevServer, fn: () => void): void {
if (server.httpServer) {
Expand Down
6 changes: 4 additions & 2 deletions packages/void/src/plugin-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Plugin } from 'vite';
import { devAuthConfigImporter } from './auth/config.ts';
import type { DatabaseDialect } from './config.ts';
import { voidLog, voidError } from './log.ts';
import { afterServerReady } from './plugin-dev-config.ts';
import { afterServerReady, isVitestServer } from './plugin-dev-config.ts';
import { readProjectPaths } from './project-paths.ts';
import { loadLocalEnv } from './shared/env.ts';

Expand Down Expand Up @@ -34,7 +34,9 @@ export function migrationPlugin(
): Plugin {
return {
name: 'void:migrations',
apply: 'serve',
// Not in Vitest's server: tests run in Node.js without the app's database bindings, and
// running them must not change the local database or the one in `.env`.
apply: (config, { command }) => command === 'serve' && !isVitestServer(config),

async configureServer(server) {
const paths = readProjectPaths(root);
Expand Down
36 changes: 35 additions & 1 deletion packages/void/test/integration/plugin-cloudflare-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { getWorkerAssetsDir, readBuildOutput } from '@cloudflare/build-output-utils';
import { createBuilder, createServer, normalizePath, preview, resolveConfig } from 'vite';
import {
createBuilder,
createServer,
normalizePath,
preview,
resolveConfig,
type InlineConfig,
} from 'vite';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
import { prepareCfBuildOutput } from '../../src/deploy.ts';
import {
Expand Down Expand Up @@ -365,6 +372,33 @@ export default {
expect(existsSync(join(root, '.gitignore'))).toBe(false);
});

it("starts no Worker in Vitest's server, in any mode, but keeps it in servers that tests start", async () => {
const root = project();
const plugins = () =>
cloudflare({
config: () => ({
name: 'app',
main: 'virtual:entry',
compatibility_date: '2026-02-24',
compatibility_flags: ['nodejs_compat'],
}),
viteEnvironment: { name: 'ssr' },
});
const pluginNames = async (config: InlineConfig) =>
(await resolveConfig({ root, configFile: false, ...config }, 'serve')).plugins.map(
(plugin) => plugin.name,
);
// Vitest adds plugins of its own to the server that runs the tests.
const vitest = { name: 'vitest:config' };
for (const mode of ['test', 'staging']) {
const names = await pluginNames({ mode, plugins: [vitest, plugins()] });
expect(names.filter((name) => name.includes('cloudflare'))).toEqual([]);
}
expect(await pluginNames({ mode: 'test', plugins: plugins() })).toContain(
'vite-plugin-cloudflare',
);
});

it('builds native output and the existing dist layout, then previews the native artifact', async () => {
const root = project();
writeFileSync(
Expand Down
Loading