From f6902af891a386c9cec6558edacf26eeb39bef47 Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Sun, 8 Nov 2026 18:30:14 +0900 Subject: [PATCH 1/3] fix(vitest): run app tests without starting the Worker `vp test` stopped with a startup error before running any test in Void apps on the default Cloudflare target, including a fresh `npm create void` app: ReferenceError: module is not defined With `nodejs_compat`, which Void turns on for auth, PostgreSQL, MySQL, Sandbox, and TanStack Start, React Router and vinext apps: Error: AssertionError: depsOptimizer is required in dev mode Vitest loads `vite.config.ts` and starts a Vite server of its own, so the Cloudflare Vite plugin that `voidPlugin()` includes started the Worker there too. Vitest turns off dependency prebundling in nearly every environment, the Worker's included, and the Cloudflare plugin relies on it: a CommonJS dependency such as React reached workerd unconverted, and the plugin's own nodejs_compat polyfill import asserted on the missing optimizer. Users could not remove the plugin, and `test.deps.optimizer` does not bring discovery back. ## Fix `isVitestServer()` recognizes the server that Vitest creates by the plugins Vitest adds to it (`vitest`, `vitest:*`), in any `--mode`. A server that a test starts through Vite's API, and `vp dev --mode test`, are not Vitest's and keep everything. In Vitest's server: - The plugins of Void's Cloudflare wrapper, the Cloudflare Vite plugin included, do not apply, in Void apps and in framework apps. Void's other plugins still resolve `void/*`, `@schema` and the generated modules, so tests can import route handlers and page loaders. - `migrationPlugin` does not apply, so a test run no longer migrates the local D1 database or the PostgreSQL or MySQL database in `.env`. - The AI project setup prints its hint instead of prompting. Tests run in Node.js, so a call that reaches a binding still throws `env: Cloudflare env is unavailable`; `vi.mock()` can replace the binding module. ## Tests - Unit: the detection, the Cloudflare wrapper and the migration plugin with and without Vitest's plugins (modes `test` and `staging`), and the AI setup prompt. - Integration: `vp test` in isolated copies of the React Pages playground (as is, with `nodejs_compat` and with `--mode staging`) and the TanStack Start playground. - Packed consumer: Vite+ fixtures run `vp test` with a mocked binding. ## Docs Add a Testing guide: `vp test` needs no configuration, tests run in Node.js without bindings, and test files stay out of the convention directories. Link it from the email testing sections and the Void skill, and note the Vitest server in the package AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/packed-consumer.mjs | 18 ++- TESTING.md | 3 +- docs/.vitepress/config.ts | 1 + docs/guide/email/receiving.md | 2 +- docs/guide/email/sending.md | 2 +- docs/guide/testing.md | 61 ++++++++ packages/void/AGENTS.md | 1 + packages/void/skills/void/SKILL.md | 1 + packages/void/src/index.ts | 5 +- packages/void/src/plugin-cloudflare.ts | 23 ++- packages/void/src/plugin-dev-config.ts | 29 +++- packages/void/src/plugin-migration.ts | 6 +- .../integration/plugin-cloudflare-v2.test.ts | 36 ++++- .../void/test/integration/vitest-app.test.ts | 134 ++++++++++++++++++ .../test/unit/plugin-ai-onboarding.test.ts | 79 +++++++++++ .../void/test/unit/plugin-dev-config.test.ts | 25 ++++ .../void/test/unit/plugin-migration.test.ts | 27 ++++ 17 files changed, 442 insertions(+), 11 deletions(-) create mode 100644 docs/guide/testing.md create mode 100644 packages/void/test/integration/vitest-app.test.ts create mode 100644 packages/void/test/unit/plugin-ai-onboarding.test.ts diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 4c47be4a5..4e36188d2 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -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 {\n const visits = ((await kv.get('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();\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, @@ -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) }); diff --git a/TESTING.md b/TESTING.md index 66b3831ab..e704dc5e0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 62ea30be9..11f2f86a3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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' }, diff --git a/docs/guide/email/receiving.md b/docs/guide/email/receiving.md index c48c1ecec..f8b5ef693 100644 --- a/docs/guide/email/receiving.md +++ b/docs/guide/email/receiving.md @@ -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. diff --git a/docs/guide/email/sending.md b/docs/guide/email/sending.md index a3de23f2b..8a41e3b18 100644 --- a/docs/guide/email/sending.md +++ b/docs/guide/email/sending.md @@ -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). diff --git a/docs/guide/testing.md b/docs/guide/testing.md new file mode 100644 index 000000000..17d461484 --- /dev/null +++ b/docs/guide/testing.md @@ -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 { + const visits = ((await kv.get('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(); + 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. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 2f7ce655f..2562f77c3 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -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. diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 6fac494a3..a83e85722 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -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` | diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 38f2d8aa0..e46d6ed67 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -43,6 +43,7 @@ import { isViteBuildOrPreview, getOrCreateDevTriggerToken, devTriggerSecretGuardPlugin, + isVitestServer, selectVoidCloudflareLogin, } from './plugin-dev-config.ts'; import { devTriggersHintPlugin } from './plugin-dev-triggers-hint.ts'; @@ -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(); diff --git a/packages/void/src/plugin-cloudflare.ts b/packages/void/src/plugin-cloudflare.ts index d27c4bfc8..f07c40cf1 100644 --- a/packages/void/src/plugin-cloudflare.ts +++ b/packages/void/src/plugin-cloudflare.ts @@ -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'; @@ -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): Array { + 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; @@ -218,7 +237,7 @@ export function cloudflare(options: { }, }, }; - return [ + return skipOnVitestServer([ bridge, directories, ...withLocalPreviewBindings( @@ -237,5 +256,5 @@ export function cloudflare(options: { root, ), output, - ]; + ]); } diff --git a/packages/void/src/plugin-dev-config.ts b/packages/void/src/plugin-dev-config.ts index 08d65e0e2..2027da1e2 100644 --- a/packages/void/src/plugin-dev-config.ts +++ b/packages/void/src/plugin-dev-config.ts @@ -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; +}): 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) { diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts index 4138f3d67..415715d70 100644 --- a/packages/void/src/plugin-migration.ts +++ b/packages/void/src/plugin-migration.ts @@ -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'; @@ -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); diff --git a/packages/void/test/integration/plugin-cloudflare-v2.test.ts b/packages/void/test/integration/plugin-cloudflare-v2.test.ts index 24209f596..90ee5ee9a 100644 --- a/packages/void/test/integration/plugin-cloudflare-v2.test.ts +++ b/packages/void/test/integration/plugin-cloudflare-v2.test.ts @@ -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 { @@ -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( diff --git a/packages/void/test/integration/vitest-app.test.ts b/packages/void/test/integration/vitest-app.test.ts new file mode 100644 index 000000000..66a1a3338 --- /dev/null +++ b/packages/void/test/integration/vitest-app.test.ts @@ -0,0 +1,134 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import spawn from 'cross-spawn'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + createIsolatedPlayground, + type IsolatedPlayground, +} from '../../../../test/integration-helpers.ts'; + +const repository = join(import.meta.dirname, '../../../..'); + +// Run the app's tests as `vp test` runs them in the app's own terminal, not as part of this run. +const appTestEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith('VITEST') && key !== 'TEST' && key !== 'NODE_ENV', + ), +); + +async function runAppTests(root: string, args: Array = []) { + const child = spawn('vp', ['test', 'run', ...args], { + cwd: root, + env: appTestEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout?.on('data', (chunk: Buffer) => (output += chunk)); + child.stderr?.on('data', (chunk: Buffer) => (output += chunk)); + const code = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + return { code, output }; +} + +function writeTest(root: string, source: string): void { + mkdirSync(join(root, 'tests'), { recursive: true }); + writeFileSync(join(root, 'tests/app.test.ts'), source); +} + +describe('vp test in a Void app', () => { + let playground: IsolatedPlayground; + + beforeAll(() => { + playground = createIsolatedPlayground(repository, 'playground/react-pages'); + writeTest( + playground.root, + `import { Hono } from 'hono'; +import { describe, expect, it } from 'vite-plus/test'; +import * as schema from '@schema'; +import { db } from 'void/db'; +import { loader } from '../pages/index.server.ts'; +import { GET } from '../routes/api/health.ts'; + +describe('app', () => { + it('runs a route handler', async () => { + const response = await new Hono().get('/api/health', GET).request('/api/health'); + expect(await response.json()).toEqual({ status: 'ok' }); + }); + + it('imports a page loader', () => { + expect(loader).toBeTypeOf('function'); + }); + + it('imports the schema and void/db', () => { + expect(Object.keys(schema)).toContain('users'); + expect(db).toBeDefined(); + }); +}); +`, + ); + }); + + afterAll(() => { + playground?.cleanup(); + }); + + it('runs the tests without starting the Worker or migrating the local database', async () => { + const { code, output } = await runAppTests(playground.root); + + expect(code, output).toBe(0); + expect(output).toContain('3 passed'); + expect(output).not.toMatch(/Applied \d+ migration/); + expect(existsSync(join(playground.root, '.void/v3'))).toBe(false); + }); + + it('runs them with nodejs_compat and in another mode', async () => { + const configPath = join(playground.root, 'void.config.ts'); + const config = readFileSync(configPath, 'utf8'); + const compatibilityDate = "compatibility_date: '2026-02-24',"; + expect(config).toContain(compatibilityDate); + writeFileSync( + configPath, + config.replace( + compatibilityDate, + `${compatibilityDate}\n compatibility_flags: ['nodejs_compat'],`, + ), + ); + + const { code, output } = await runAppTests(playground.root, ['--mode', 'staging']); + + expect(code, output).toBe(0); + expect(output).toContain('3 passed'); + }); +}); + +describe('vp test in a framework app', () => { + let playground: IsolatedPlayground; + + beforeAll(() => { + // Void turns on nodejs_compat for TanStack Start, React Router and vinext apps. + playground = createIsolatedPlayground(repository, 'playground/tanstack-start'); + writeTest( + playground.root, + `import { expect, it } from 'vite-plus/test'; +import * as schema from '@schema'; + +it('imports the schema', () => { + expect(Object.keys(schema)).toContain('users'); +}); +`, + ); + }); + + afterAll(() => { + playground?.cleanup(); + }); + + it('runs the tests', async () => { + const { code, output } = await runAppTests(playground.root); + + expect(code, output).toBe(0); + expect(output).toContain('1 passed'); + }); +}); diff --git a/packages/void/test/unit/plugin-ai-onboarding.test.ts b/packages/void/test/unit/plugin-ai-onboarding.test.ts new file mode 100644 index 000000000..62b83afb9 --- /dev/null +++ b/packages/void/test/unit/plugin-ai-onboarding.test.ts @@ -0,0 +1,79 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ConfigEnv, PluginOption, UserConfig, ViteDevServer } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { getHookHandler } from '../helpers/vite-hooks.ts'; + +const runAiSetupPrompt = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/plugin-cloudflare.ts', () => import('../helpers/cloudflare-vite-plugin.ts')); +vi.mock('../../src/plugin-ai-setup.ts', async (importOriginal) => ({ + ...(await importOriginal()), + // Signed in to Void, with no project linked. + readVoidDevCredentials: () => ({ + status: 'no-project', + apiUrl: 'https://void.example', + token: 'token', + }), + runAiSetupPrompt, +})); + +const roots: Array = []; +const isTTY = process.stdin.isTTY; + +afterEach(() => { + process.stdin.isTTY = isTTY; + runAiSetupPrompt.mockClear(); + vi.restoreAllMocks(); + vi.resetModules(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +/** Starts `void:env` for an app that uses `void/ai`, in a server with the given plugins. */ +async function startAiApp(plugins: Array): Promise { + const root = mkdtempSync(join(tmpdir(), 'void-ai-onboarding-')); + roots.push(root); + writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ worker: { compatibility_date: '2026-02-24' } }), + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/chat.ts'), + "import { ai } from 'void/ai';\n\nexport const POST = () => ai;\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const { voidPlugin } = await import('../../src/index.ts'); + const envPlugin = voidPlugin().find((plugin) => plugin.name === 'void:env'); + const config = getHookHandler(envPlugin?.config) as (config: UserConfig, env: ConfigEnv) => void; + config({}, { command: 'serve', mode: 'development' }); + const configureServer = getHookHandler(envPlugin?.configureServer) as ( + server: ViteDevServer, + ) => void; + // Without an HTTP server, the setup starts right away. + configureServer({ config: { plugins }, httpServer: null } as unknown as ViteDevServer); +} + +describe('AI project setup in development', () => { + it('asks which project to link when the dev server starts in a terminal', async () => { + process.stdin.isTTY = true; + await startAiApp([]); + + expect(runAiSetupPrompt).toHaveBeenCalledOnce(); + }); + + it("prints how to link one instead in Vitest's server", async () => { + process.stdin.isTTY = true; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + // Vitest adds plugins of its own to the server that runs the tests. + await startAiApp([{ name: 'vitest:config' }]); + + expect(runAiSetupPrompt).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join('\n')).toContain('AI requires a linked project.'); + }); +}); diff --git a/packages/void/test/unit/plugin-dev-config.test.ts b/packages/void/test/unit/plugin-dev-config.test.ts index 04cf67be4..69d8ba0c5 100644 --- a/packages/void/test/unit/plugin-dev-config.test.ts +++ b/packages/void/test/unit/plugin-dev-config.test.ts @@ -1,7 +1,9 @@ +import type { PluginOption } from 'vite'; import { describe, expect, it } from 'vite-plus/test'; import { devTriggerSecretGuardPlugin, isDevTriggerSecretRequest, + isVitestServer, } from '../../src/plugin-dev-config.ts'; describe('isDevTriggerSecretRequest', () => { @@ -71,3 +73,26 @@ describe('devTriggerSecretGuardPlugin', () => { expect(devTriggerSecretGuardPlugin().enforce).toBe('pre'); }); }); + +describe('isVitestServer', () => { + it.each<[Array]>([ + // Vitest 5 names every plugin of its server `vitest:*`. + [[{ name: 'vite-plugin-cloudflare' }, { name: 'vitest:config' }]], + // Vitest 3 and 4 also name their main plugin `vitest`. + [[{ name: 'vitest' }]], + // Vite flattens nested arrays and ignores falsy entries. + [[false, null, [[{ name: 'vitest:test-config' }]]]], + ])('finds Vitest in %j', (plugins) => { + expect(isVitestServer({ plugins })).toBe(true); + }); + + it.each<[Array | undefined]>([ + [undefined], + [[]], + [[false, [{ name: 'vite-plugin-cloudflare' }, { name: 'void:env' }]]], + // A plugin named after Vitest is not Vitest's own. + [[{ name: 'vitest-plugin-example' }]], + ])('does not find Vitest in %j', (plugins) => { + expect(isVitestServer({ plugins })).toBe(false); + }); +}); diff --git a/packages/void/test/unit/plugin-migration.test.ts b/packages/void/test/unit/plugin-migration.test.ts index 094a8be9f..d015d81d0 100644 --- a/packages/void/test/unit/plugin-migration.test.ts +++ b/packages/void/test/unit/plugin-migration.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node: import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; +import { createServer, type PluginOption } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; import { migrationPlugin } from '../../src/plugin-migration.ts'; @@ -123,6 +124,32 @@ describe('migrationPlugin persistence roots', () => { }); }); +describe("migrationPlugin in Vitest's server", () => { + it('applies migrations only in other dev servers, whatever the mode', async () => { + const root = createProjectRoot(); + const dbPath = getMiniflareDatabasePath(join(root, '.void'), 'local'); + const startServer = async (mode: string, plugins: Array) => { + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + mode, + server: { middlewareMode: true, ws: false }, + plugins, + }); + await server.close(); + }; + // Vitest adds plugins of its own to the server that runs the tests. + for (const mode of ['test', 'staging']) { + await startServer(mode, [{ name: 'vitest:config' }, migrationPlugin(root, 'sqlite')]); + expect(existsSync(dbPath)).toBe(false); + } + // A server that a test starts has none, even in mode `test`. + await startServer('test', [migrationPlugin(root, 'sqlite')]); + expect(existsSync(dbPath)).toBe(true); + }); +}); + describe('migrationPlugin Better Auth config', () => { it("loads the auth config through the dev server's runner, so a restarted server reads it again", async () => { const root = createProjectRoot(); From 2e38ed662124fc9dc85e2a3bba6636cf528db5ef Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Mon, 9 Nov 2026 21:27:29 +0900 Subject: [PATCH 2/3] docs(email): import test APIs from vite-plus/test The Testing guide imports test APIs from `vite-plus/test`, the Vite+ toolchain's entry for Vitest, and names `vitest` for the Vite toolchain. The email testing examples still imported from `vitest`, which TypeScript cannot resolve in a pnpm-installed Vite+ app that does not install Vitest directly. Import from `vite-plus/test` there too. Co-Authored-By: Claude Opus 5.5 --- docs/guide/email/receiving.md | 2 +- docs/guide/email/sending.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/email/receiving.md b/docs/guide/email/receiving.md index f8b5ef693..1b521172e 100644 --- a/docs/guide/email/receiving.md +++ b/docs/guide/email/receiving.md @@ -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]'; diff --git a/docs/guide/email/sending.md b/docs/guide/email/sending.md index 8a41e3b18..5f49dc0c0 100644 --- a/docs/guide/email/sending.md +++ b/docs/guide/email/sending.md @@ -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'; From fb88e58d032fa4cebfb233463ee505fce12f38e0 Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Mon, 9 Nov 2026 21:46:15 +0900 Subject: [PATCH 3/3] test(migrations): start the Vitest server checks without a file watcher The unit test that starts Vite dev servers to check where migrations apply needs no file watching. Pass `watch: null`, as the other unit tests that start a dev server do, so no native file watcher runs in the unit project's worker threads. Co-Authored-By: Claude Opus 5.5 --- packages/void/test/unit/plugin-migration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/void/test/unit/plugin-migration.test.ts b/packages/void/test/unit/plugin-migration.test.ts index d015d81d0..a5037a383 100644 --- a/packages/void/test/unit/plugin-migration.test.ts +++ b/packages/void/test/unit/plugin-migration.test.ts @@ -134,7 +134,7 @@ describe("migrationPlugin in Vitest's server", () => { configFile: false, logLevel: 'silent', mode, - server: { middlewareMode: true, ws: false }, + server: { middlewareMode: true, ws: false, watch: null }, plugins, }); await server.close();