diff --git a/package.json b/package.json index 93ac1023f..167e17744 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,6 @@ "@testing-library/vue": "8.0.1", "@types/estree": "1.0.5", "@types/jsdom": "21.1.6", - "@vitejs/plugin-vue": "4.5.1", - "@vitejs/plugin-vue-jsx": "3.1.0", "@vitest/ui": "0.33.0", "@vue/test-utils": "2.4.3", "changelogen": "0.5.5", @@ -89,8 +87,6 @@ "peerDependencies": { "@jest/globals": "^29.5.0", "@testing-library/vue": "^7.0.0 || ^8.0.1", - "@vitejs/plugin-vue": "*", - "@vitejs/plugin-vue-jsx": "*", "@vitest/ui": "0.33.0", "@vue/test-utils": "^2.4.2", "h3": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de9d2e394..a6ef17429 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,12 +105,6 @@ importers: '@types/jsdom': specifier: 21.1.6 version: 21.1.6 - '@vitejs/plugin-vue': - specifier: 4.5.1 - version: 4.5.1(vite@5.0.4)(vue@3.3.9) - '@vitejs/plugin-vue-jsx': - specifier: 3.1.0 - version: 3.1.0(vite@5.0.4)(vue@3.3.9) '@vitest/ui': specifier: 0.33.0 version: 0.33.0(vitest@0.33.0) diff --git a/src/config.ts b/src/config.ts index b8a4fe4c0..672ec5281 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,9 @@ -import type { Nuxt, NuxtConfig, ViteConfig } from '@nuxt/schema' +import type { Nuxt, NuxtConfig } from '@nuxt/schema' import type { InlineConfig as VitestConfig } from 'vitest' import { defineConfig } from 'vite' import type { InlineConfig } from 'vite' -import vuePlugin from '@vitejs/plugin-vue' -import viteJsxPlugin from '@vitejs/plugin-vue-jsx' import { defu } from 'defu' +import { createResolver } from '@nuxt/kit' interface GetVitestConfigOptions { nuxt: Nuxt @@ -39,7 +38,7 @@ async function startNuxtAndGetViteConfig( } const promise = new Promise((resolve, reject) => { - nuxt.hook('vite:extendConfig', (viteConfig, { isClient }) => { + nuxt.hook('vite:configResolved', (viteConfig, { isClient }) => { if (isClient) { resolve({ nuxt, viteConfig }) throw new Error('_stop_') @@ -55,35 +54,24 @@ async function startNuxtAndGetViteConfig( return promise } -const vuePlugins = { - 'vite:vue': [vuePlugin, 'vue'], - 'vite:vue-jsx': [viteJsxPlugin, 'vueJsx'], -} as const - export async function getVitestConfigFromNuxt( options?: GetVitestConfigOptions, overrides?: NuxtConfig ): Promise { const { rootDir = process.cwd(), ..._overrides } = overrides || {} - if (!options) options = await startNuxtAndGetViteConfig(rootDir, { - test: true, - ..._overrides - }) - options.viteConfig.plugins = options.viteConfig.plugins || [] - options.viteConfig.plugins = options.viteConfig.plugins.filter( - p => (p as any)?.name !== 'nuxt:import-protection' - ) - for (const name in vuePlugins) { - if (!options.viteConfig.plugins?.some(p => (p as any)?.name === name)) { - const [plugin, key] = vuePlugins[name as keyof typeof vuePlugins] - options.viteConfig.plugins.unshift( - // @ts-expect-error mismatching component options - plugin((options.viteConfig as ViteConfig)[key]) - ) - } + if (!options) { + options = await startNuxtAndGetViteConfig(rootDir, { + test: true, + ..._overrides + }) } + options.viteConfig.plugins ||= [] + options.viteConfig.plugins = options.viteConfig.plugins?.filter( + p => (p as any)?.name !== 'nuxt:import-protection' + ) + const resolvedConfig = defu( // overrides { @@ -111,6 +99,8 @@ export async function getVitestConfigFromNuxt( /^#/, // additional deps '@nuxt/test-utils', + '@nuxt/test-utils-nightly', + '@nuxt/test-utils-edge', 'vitest-environment-nuxt', ...(options.nuxt.options.build.transpile.filter( r => typeof r === 'string' || r instanceof RegExp @@ -157,6 +147,13 @@ export async function getVitestConfigFromNuxt( // TODO: fix this by separating nuxt/node vitest configs // typescript currently checks this to determine if it can access the filesystem: https://github.com/microsoft/TypeScript/blob/d4fbc9b57d9aa7d02faac9b1e9bb7b37c687f6e9/src/compiler/core.ts#L2738-L2749 delete resolvedConfig.define!['process.browser'] + + if (!Array.isArray(resolvedConfig.test.setupFiles)) { + resolvedConfig.test.setupFiles = [resolvedConfig.test.setupFiles].filter(Boolean) as string[] + } + + const resolver = createResolver(import.meta.url) + resolvedConfig.test.setupFiles.unshift(resolver.resolve('./runtime/entry')) return resolvedConfig } diff --git a/src/environments/vitest/index.ts b/src/environments/vitest/index.ts index cb0e17e1e..bf19af15c 100644 --- a/src/environments/vitest/index.ts +++ b/src/environments/vitest/index.ts @@ -30,6 +30,8 @@ export default { jsdom: { url }, })) + win.__NUXT_VITEST_ENVIRONMENT__ = true + win.__NUXT__ = { serverRendered: false, config: { @@ -137,14 +139,12 @@ export default { registry.add(`${manifestOutputPath}/meta/test.json`) registry.add(`${manifestOutputPath}/meta/dev.json`) - await import('#app/entry').then(r => r.default()) - return { // called after all tests with this env have been run teardown() { - teardown() keys.forEach(key => delete global[key]) originals.forEach((v, k) => (global[k] = v)) + teardown() }, } }, diff --git a/src/environments/vitest/types.ts b/src/environments/vitest/types.ts index 9f5440c88..456a9047b 100644 --- a/src/environments/vitest/types.ts +++ b/src/environments/vitest/types.ts @@ -4,6 +4,7 @@ export type NuxtBuiltinEnvironment = 'happy-dom' | 'jsdom' export interface NuxtWindow extends Window { __app: App __registry: Set + __NUXT_VITEST_ENVIRONMENT__?: boolean __NUXT__: any $fetch: any fetch: any diff --git a/src/module.ts b/src/module.ts index 4e6209f00..1f78105dd 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,15 +1,17 @@ import { pathToFileURL } from 'node:url' -import { defineNuxtModule, logger, resolvePath } from '@nuxt/kit' +import { addVitePlugin, createResolver, defineNuxtModule, logger, resolvePath } from '@nuxt/kit' import type { File, Reporter, Vitest, UserConfig as VitestConfig } from 'vitest' import { mergeConfig } from 'vite' import type { InlineConfig as ViteConfig } from 'vite' -import { getVitestConfigFromNuxt } from './config' import { getPort } from 'get-port-please' import { h } from 'vue' import { debounce } from 'perfect-debounce' import { isCI } from 'std-env' +import { defu } from 'defu' +import { getVitestConfigFromNuxt } from './config' import { setupImportMocking } from './module/mock' +import { NuxtRootStubPlugin } from './module/plugins/entry' export interface NuxtVitestOptions { startOnBoot?: boolean @@ -37,6 +39,12 @@ export default defineNuxtModule({ setupImportMocking() } + const resolver = createResolver(import.meta.url) + addVitePlugin(NuxtRootStubPlugin.vite({ + entry: await resolvePath('#app/entry', { alias: nuxt.options.alias }), + rootStubPath: await resolvePath(resolver.resolve('./runtime/nuxt-root')), + })) + if (!nuxt.options.dev) return if (nuxt.options.test && nuxt.options.app.rootId === '__nuxt') { @@ -49,7 +57,7 @@ export default defineNuxtModule({ const rawViteConfigPromise = new Promise(resolve => { // Wrap with app:resolve to ensure we got the final vite config nuxt.hook('app:resolve', () => { - nuxt.hook('vite:extendConfig', (config, { isClient }) => { + nuxt.hook('vite:configResolved', (config, { isClient }) => { if (isClient) resolve(config) }) }) @@ -69,16 +77,14 @@ export default defineNuxtModule({ async function start() { const rawViteConfig = mergeConfig({}, await rawViteConfigPromise) - const viteConfig = await getVitestConfigFromNuxt({ nuxt, viteConfig: rawViteConfig }) + const viteConfig = await getVitestConfigFromNuxt({ nuxt, viteConfig: defu({ test: options.vitestConfig }, rawViteConfig) }) viteConfig.plugins = (viteConfig.plugins || []).filter((p: any) => { return !vitePluginBlocklist.includes(p?.name) }) process.env.__NUXT_VITEST_RESOLVED__ = 'true' - const { startVitest } = (await import( - pathToFileURL(await resolvePath('vitest/node')).href - )) as typeof import('vitest/node') + const { startVitest } = (await import(pathToFileURL(await resolvePath('vitest/node')).href)) as typeof import('vitest/node') const customReporter: Reporter = { onInit(_ctx) { @@ -97,10 +103,9 @@ export default defineNuxtModule({ const watchMode = !process.env.NUXT_VITEST_DEV_TEST && !isCI // For testing dev mode in CI, maybe expose an option to user later - const vitestConfig: VitestConfig = watchMode + const overrides: VitestConfig = watchMode ? { passWithNoTests: true, - ...options.vitestConfig, reporters: options.logToConsole ? [ ...toArray(options.vitestConfig?.reporters ?? ['default']), @@ -114,16 +119,10 @@ export default defineNuxtModule({ port: PORT, }, } - : { - ...options.vitestConfig, - watch: false, - } - - // TODO: Investigate segfault when loading config file in Nuxt - viteConfig.configFile = false + : { watch: false } // Start Vitest - const promise = startVitest('test', [], vitestConfig, viteConfig) + const promise = startVitest('test', [], defu(overrides, viteConfig.test), viteConfig) promise.catch(() => process.exit(1)) if (watchMode) { diff --git a/src/module/plugins/entry.ts b/src/module/plugins/entry.ts new file mode 100644 index 000000000..213a475c3 --- /dev/null +++ b/src/module/plugins/entry.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { createUnplugin } from "unplugin" + +const PLUGIN_NAME = 'nuxt:vitest:nuxt-root-stub' + +interface NuxtRootStubPluginOptions { + entry: string + rootStubPath: string +} + +export const NuxtRootStubPlugin = createUnplugin((options: NuxtRootStubPluginOptions) => { + return { + name: PLUGIN_NAME, + enforce: 'pre', + vite: { + async resolveId(id) { + if (id.endsWith('nuxt-vitest-app-entry')) { + return id + } + }, + async load(id) { + if (id.endsWith('nuxt-vitest-app-entry')) { + const entryContents = readFileSync(options.entry, 'utf-8') + return entryContents.replace('#build/root-component.mjs', options.rootStubPath) + } + } + }, + } +}) diff --git a/src/module/plugins/mock.ts b/src/module/plugins/mock.ts index 153b2c2c9..d0366d347 100644 --- a/src/module/plugins/mock.ts +++ b/src/module/plugins/mock.ts @@ -7,6 +7,7 @@ import type { Component } from '@nuxt/schema' import type { Plugin } from 'vite' import { normalize, resolve } from 'node:path' import { createUnplugin } from 'unplugin' +import { resolvePath } from '@nuxt/kit' export interface MockPluginContext { imports: Import[] @@ -193,30 +194,20 @@ export const createMockPlugin = (ctx: MockPluginContext) => createUnplugin(() => ([from, mocks]) => { importPathsList.add(from) const lines = [ - `vi.mock(${JSON.stringify( - from - )}, async (importOriginal) => {`, - ` const mocks = global.${HELPER_MOCK_HOIST}`, - ` if (!mocks[${JSON.stringify( - from - )}]) { mocks[${JSON.stringify( - from - )}] = { ...await importOriginal(${JSON.stringify( - from - )}) } }`, + `vi.mock(${JSON.stringify(from)}, async (importOriginal) => {`, + ` const mocks = globalThis.${HELPER_MOCK_HOIST}`, + ` if (!mocks[${JSON.stringify(from)}]) {`, + ` mocks[${JSON.stringify(from)}] = { ...await importOriginal(${JSON.stringify(from)}) }`, + ` }`, ] for (const mock of mocks) { if (mock.import.name === 'default') { lines.push( - ` mocks[${JSON.stringify(from)}]["default"] = await (${ - mock.factory - })();` + ` mocks[${JSON.stringify(from)}]["default"] = await (${mock.factory})();` ) } else { lines.push( - ` mocks[${JSON.stringify(from)}][${JSON.stringify( - mock.name - )}] = await (${mock.factory})();` + ` mocks[${JSON.stringify(from)}][${JSON.stringify(mock.name)}] = await (${mock.factory})();` ) } } @@ -245,7 +236,7 @@ export const createMockPlugin = (ctx: MockPluginContext) => createUnplugin(() => if (!mockLines.length) return s.prepend(`vi.hoisted(() => { - if(!global.${HELPER_MOCK_HOIST}){ + if(!globalThis.${HELPER_MOCK_HOIST}){ vi.stubGlobal(${JSON.stringify(HELPER_MOCK_HOIST)}, {}) } });\n`) @@ -274,28 +265,29 @@ export const createMockPlugin = (ctx: MockPluginContext) => createUnplugin(() => vite: { transform, // Place Vitest's mock plugin after all Nuxt plugins - configResolved(config) { + async configResolved(config) { const firstSetupFile = Array.isArray(config.test?.setupFiles) - ? config.test!.setupFiles[0] + ? config.test!.setupFiles.find(p => !p.includes('runtime/entry')) : config.test?.setupFiles if (firstSetupFile) { - resolvedFirstSetupFile = normalize(resolve(firstSetupFile)) + resolvedFirstSetupFile = await resolvePath(normalize(resolve(firstSetupFile))) } const plugins = (config.plugins || []) as Plugin[] + // `vite:mocks` was a typo in Vitest before v0.34.0 - const mockPluginIndex = plugins.findIndex( - i => i.name === 'vite:mocks' || i.name === 'vitest:mocks' - ) + const vitestPlugins = plugins.filter(p => p.name === 'vite:mocks' || p.name.startsWith('vitest:')) const lastNuxt = findLastIndex( plugins, i => i.name?.startsWith('nuxt:') ) - if (mockPluginIndex !== -1 && lastNuxt !== -1) { - if (mockPluginIndex < lastNuxt) { - const [mockPlugin] = plugins.splice(mockPluginIndex, 1) - plugins.splice(lastNuxt, 0, mockPlugin) + if (lastNuxt === -1) return + for (const plugin of vitestPlugins) { + const index = plugins.indexOf(plugin) + if (index < lastNuxt) { + plugins.splice(index, 1) + plugins.splice(lastNuxt, 0, plugin) } } }, diff --git a/src/runtime/entry.ts b/src/runtime/entry.ts new file mode 100644 index 000000000..598e38439 --- /dev/null +++ b/src/runtime/entry.ts @@ -0,0 +1,15 @@ +if ( + typeof window !== 'undefined' && + // @ts-expect-error undefined property + window.__NUXT_VITEST_ENVIRONMENT__ +) { + // @ts-expect-error alias to allow us to transform the entrypoint + await import('#app/nuxt-vitest-app-entry').then(r => r.default()) + // We must manually call `page:finish` to snc route after navigation + // as there is no `` instantiated by default. + const nuxtApp = useNuxtApp() + await nuxtApp.callHook('page:finish') + useRouter().afterEach(() => nuxtApp.callHook('page:finish')) +} + +export {} diff --git a/src/runtime/nuxt-root.ts b/src/runtime/nuxt-root.ts new file mode 100644 index 000000000..ea095a9c3 --- /dev/null +++ b/src/runtime/nuxt-root.ts @@ -0,0 +1,30 @@ +import { Suspense, defineComponent, h, onErrorCaptured, provide } from 'vue' +import { isNuxtError, useNuxtApp, useRoute } from '#imports' +import { PageRouteSymbol } from '#app/components/injections' + +export default defineComponent({ + setup (_options, { slots }) { + const nuxtApp = useNuxtApp() + + // Inject default route (outside of pages) as active route + provide(PageRouteSymbol, useRoute()) + + const done = nuxtApp.deferHydration() + + // vue:setup hook + const results = nuxtApp.hooks.callHookWith(hooks => hooks.map(hook => hook()), 'vue:setup') + if (import.meta.dev && results && results.some(i => i && 'then' in i)) { + console.error('[nuxt] Error in `vue:setup`. Callbacks must be synchronous.') + } + + // error handling + onErrorCaptured((err, target, info) => { + nuxtApp.hooks.callHook('vue:error', err, target, info).catch(hookError => console.error('[nuxt] Error in `vue:error` hook', hookError)) + if (isNuxtError(err) && (err.fatal || err.unhandled)) { + return false // suppress error from breaking render + } + }) + + return () => h(Suspense, { onResolve: done }, slots.default?.()) + } +}) diff --git a/test/unit/mock-transform.spec.ts b/test/unit/mock-transform.spec.ts index ffbcbc208..d0df02643 100644 --- a/test/unit/mock-transform.spec.ts +++ b/test/unit/mock-transform.spec.ts @@ -50,13 +50,15 @@ describe('mocking', () => { `)).toMatchInlineSnapshot(` "import {vi} from \\"vitest\\"; vi.hoisted(() => { - if(!global.__NUXT_VITEST_MOCKS){ + if(!globalThis.__NUXT_VITEST_MOCKS){ vi.stubGlobal(\\"__NUXT_VITEST_MOCKS\\", {}) } }); vi.mock(\\"bob\\", async (importOriginal) => { - const mocks = global.__NUXT_VITEST_MOCKS - if (!mocks[\\"bob\\"]) { mocks[\\"bob\\"] = { ...await importOriginal(\\"bob\\") } } + const mocks = globalThis.__NUXT_VITEST_MOCKS + if (!mocks[\\"bob\\"]) { + mocks[\\"bob\\"] = { ...await importOriginal(\\"bob\\") } + } mocks[\\"bob\\"][\\"useSomeExport\\"] = await (() => { return () => 'mocked' })(); @@ -100,7 +102,7 @@ describe('mocking', () => { `)).toMatchInlineSnapshot(` "import {vi} from \\"vitest\\"; vi.hoisted(() => { - if(!global.__NUXT_VITEST_MOCKS){ + if(!globalThis.__NUXT_VITEST_MOCKS){ vi.stubGlobal(\\"__NUXT_VITEST_MOCKS\\", {}) } });