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..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]'; @@ -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..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'; @@ -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..a5037a383 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, watch: null }, + 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();