Skip to content

Commit

Permalink
fix!: load nuxt app within setupFiles (#260)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored Dec 2, 2023
1 parent 05dbe7f commit 7490334
Show file tree
Hide file tree
Showing 11 changed files with 142 additions and 87 deletions.
4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": "*",
Expand Down
6 changes: 0 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 22 additions & 25 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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_')
Expand All @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 3 additions & 3 deletions src/environments/vitest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export default <Environment>{
jsdom: { url },
}))

win.__NUXT_VITEST_ENVIRONMENT__ = true

win.__NUXT__ = {
serverRendered: false,
config: {
Expand Down Expand Up @@ -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()
},
}
},
Expand Down
1 change: 1 addition & 0 deletions src/environments/vitest/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 16 additions & 17 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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') {
Expand All @@ -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)
})
})
Expand All @@ -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) {
Expand All @@ -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']),
Expand All @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions src/module/plugins/entry.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
},
}
})
48 changes: 20 additions & 28 deletions src/module/plugins/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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})();`
)
}
}
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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)
}
}
},
Expand Down
15 changes: 15 additions & 0 deletions src/runtime/entry.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading

0 comments on commit 7490334

Please sign in to comment.