Repository: nuxt/test-utils
Pull request #260 (2023-12-01 23:55:38)

fix!: load nuxt app within `setupFiles`
This moves the Nuxt environment setup into `setupFiles` which will provide a more consistent approach in future (https://github.com/danielroe/nuxt-vitest/pull/290#issuecomment-1744345443).

I've separated out this refactor from the move to Vitest 0.34+ as the approach is backwards-compatible.

resolves https://github.com/nuxt/test-utils/issues/573

cc: @sheremet-va


Patch:
diff --git a/package.json b/package.json
index 93ac1023..167e1774 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 de9d2e39..a6ef1742 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 b8a4fe4c..672ec528 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<GetVitestConfigOptions>((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<InlineConfig & { test: VitestConfig }> {
   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/core/nuxt.ts b/src/core/nuxt.ts
index c5f557d5..b6ef9c63 100644
--- a/src/core/nuxt.ts
+++ b/src/core/nuxt.ts
@@ -43,7 +43,7 @@ export async function loadFixture () {
 
   if (!ctx.options.dev) {
     const randomId = Math.random().toString(36).slice(2, 8)
-    const buildDir = resolve(ctx.options.rootDir, '.nuxt', randomId)
+    const buildDir = ctx.options.buildDir || resolve(ctx.options.rootDir, '.nuxt', randomId)
     ctx.options.nuxtConfig = defu(ctx.options.nuxtConfig, {
       buildDir,
       nitro: {
diff --git a/src/module.ts b/src/module.ts
index 4e6209f0..1f78105d 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<NuxtVitestOptions>({
       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<NuxtVitestOptions>({
     const rawViteConfigPromise = new Promise<ViteConfig>(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<NuxtVitestOptions>({
     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<NuxtVitestOptions>({
       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<NuxtVitestOptions>({
               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 00000000..213a475c
--- /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 153b2c2c..d0366d34 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 00000000..598e3843
--- /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 `<NuxtPage>` 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 00000000..ea095a9c
--- /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?.())
+  }
+})


Test patch:
diff --git a/src/environments/vitest/index.ts b/src/environments/vitest/index.ts
index cb0e17e1..bf19af15 100644
--- a/src/environments/vitest/index.ts
+++ b/src/environments/vitest/index.ts
@@ -30,6 +30,8 @@ export default <Environment>{
       jsdom: { url },
     }))
 
+    win.__NUXT_VITEST_ENVIRONMENT__ = true
+
     win.__NUXT__ = {
       serverRendered: false,
       config: {
@@ -137,14 +139,12 @@ export default <Environment>{
     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 9f5440c8..456a9047 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<string>
+  __NUXT_VITEST_ENVIRONMENT__?: boolean
   __NUXT__: any
   $fetch: any
   fetch: any
diff --git a/test/unit/mock-transform.spec.ts b/test/unit/mock-transform.spec.ts
index ffbcbc20..d0df0264 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\\", {})
                 }
               });