diff --git a/src/vitest-environment-nuxt/config.ts b/src/vitest-environment-nuxt/config.ts new file mode 100644 index 0000000000..e4fd240a5f --- /dev/null +++ b/src/vitest-environment-nuxt/config.ts @@ -0,0 +1,78 @@ +import type { Nuxt } from '@nuxt/schema' +import type { InlineConfig as VitestConfig } from 'vitest' +import { InlineConfig, mergeConfig, defineConfig } from 'vite' +import modules from './module' + +interface GetVitestConfigOptions { + nuxt: Nuxt + viteConfig: InlineConfig +} + +// https://github.com/nuxt/framework/issues/6496 +async function getNuxtAndViteConfig(rootDir = process.cwd()) { + const { loadNuxt, buildNuxt } = await import('@nuxt/kit') + const nuxt = await loadNuxt({ + cwd: rootDir, + dev: false, + ready: false, + overrides: { + ssr: false, + app: { + rootId: 'nuxt-test', + }, + }, + }) + nuxt.options.modules.push(modules) + await nuxt.ready() + + return new Promise((resolve, reject) => { + nuxt.hook('vite:extendConfig', viteConfig => { + resolve({ nuxt, viteConfig }) + throw new Error('_stop_') + }) + buildNuxt(nuxt).catch(err => { + if (!err.toString().includes('_stop_')) { + reject(err) + } + }) + }).finally(() => nuxt.close()) +} + +export async function getVitestConfig( + options?: GetVitestConfigOptions +): Promise { + if (!options) options = await getNuxtAndViteConfig() + + return { + ...options.viteConfig, + test: { + ...options.viteConfig.test, + dir: options.nuxt.options.rootDir, + environment: 'nuxt', + deps: { + ...options.viteConfig.test?.deps, + inline: + options.viteConfig.test?.deps?.inline === true + ? true + : [ + // vite-node defaults + /\/(nuxt|nuxt3)\//, + /^#/, + // additional deps + 'vue', + 'vitest-environment-nuxt', + ...(options.nuxt.options.build.transpile.filter( + r => typeof r === 'string' || r instanceof RegExp + ) as Array), + ...(options.viteConfig.test?.deps?.inline || []), + ], + }, + }, + } +} + +export function defineConfigWithNuxtEnv(config: InlineConfig = {}) { + return defineConfig(async () => { + return mergeConfig(await getVitestConfig(), config) + }) +} diff --git a/src/vitest-environment-nuxt/index.ts b/src/vitest-environment-nuxt/index.ts new file mode 100644 index 0000000000..cb7e7a4f48 --- /dev/null +++ b/src/vitest-environment-nuxt/index.ts @@ -0,0 +1,82 @@ +import type { Environment } from 'vitest' +import { Window, GlobalWindow } from 'happy-dom' +import { createFetch } from 'ofetch' +import { createApp, toNodeListener } from 'h3' +import type { App } from 'h3' +import { populateGlobal } from 'vitest/environments' +import { + createCall, + createFetch as createLocalFetch, +} from 'unenv/runtime/fetch/index' + +export default { + name: 'nuxt', + async setup() { + const win = new (GlobalWindow || Window)() as any as Window & { + __app: App + __registry: Set + __NUXT__: any + $fetch: any + fetch: any + IntersectionObserver: any + } + + win.__NUXT__ = { + serverRendered: false, + config: { + public: {}, + app: { baseURL: '/' }, + }, + data: {}, + state: {}, + } + + const app = win.document.createElement('div') + // this is a workaround for a happy-dom bug with ids beginning with _ + app.id = 'nuxt-test' + win.document.body.appendChild(app) + + win.IntersectionObserver = + win.IntersectionObserver || + class IntersectionObserver { + observe() {} + } + + const h3App = createApp() + + // @ts-expect-error TODO: fix in h3 + const localCall = createCall(toNodeListener(h3App)) + const localFetch = createLocalFetch(localCall, globalThis.fetch) + + const registry = new Set() + + win.fetch = (init: string, options?: any) => { + if (typeof init === 'string' && registry.has(init)) { + init = '/_' + init + } + return localFetch(init, options) + } + + win.$fetch = createFetch({ fetch: win.fetch, Headers: win.Headers as any }) + + win.__registry = registry + win.__app = h3App + + const { keys, originals } = populateGlobal(global, win, { + bindFunctions: true, + }) + + await import('#app/entry') + + return { + // called after all tests with this env have been run + teardown() { + win.happyDOM.cancelAsync() + // @ts-expect-error + keys.forEach(key => delete global[key]) + // @ts-expect-error + originals.forEach((v, k) => (global[k] = v)) + }, + } + }, +} diff --git a/src/vitest-environment-nuxt/module.ts b/src/vitest-environment-nuxt/module.ts new file mode 100644 index 0000000000..b3d53f34c2 --- /dev/null +++ b/src/vitest-environment-nuxt/module.ts @@ -0,0 +1,11 @@ +import { defineNuxtModule, installModule } from '@nuxt/kit' +import autoImportMock from './modules/auto-import-mock' + +export default defineNuxtModule({ + meta: { + name: 'vitest-env', + }, + async setup() { + await installModule(autoImportMock) + }, +}) diff --git a/src/vitest-environment-nuxt/modules/auto-import-mock.ts b/src/vitest-environment-nuxt/modules/auto-import-mock.ts new file mode 100644 index 0000000000..2f16fd266c --- /dev/null +++ b/src/vitest-environment-nuxt/modules/auto-import-mock.ts @@ -0,0 +1,127 @@ +import type { Import } from 'unimport' +import { addVitePlugin, defineNuxtModule } from '@nuxt/kit' +import { walk } from 'estree-walker' +import type { CallExpression } from 'estree' +import { AcornNode } from 'rollup' +import MagicString from 'magic-string' + +const HELPER_NAME = 'mockNuxtImport' + +export interface MockInfo { + name: string + import: Import + factory: string + start: number + end: number +} + +/** + * This module is a macro that transforms `mockNuxtImport()` to `vi.mock()`, + * which make it possible to mock Nuxt imports. + */ +export default defineNuxtModule({ + meta: { + name: 'vitest-env:auto-import-mock', + }, + setup(_, nuxt) { + let imports: Import[] = [] + + nuxt.hook('imports:extend', _ => { + imports = _ + }) + + addVitePlugin({ + name: 'nuxt:auto-import-mock', + transform(code, id) { + if (!code.includes(HELPER_NAME)) return + if (id.includes('/node_modules/')) return + + let ast: AcornNode + try { + ast = this.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + ranges: true, + }) + } catch (e) { + return + } + + const mocks: MockInfo[] = [] + + walk(ast, { + enter: node => { + if (node.type !== 'CallExpression') return + const call = node as CallExpression + if ( + call.callee.type !== 'Identifier' || + call.callee.name !== HELPER_NAME + ) { + return + } + if (call.arguments.length !== 2) { + return + } + if (call.arguments[0].type !== 'Literal') { + return // TODO: warn + } + const name = call.arguments[0].value as string + const importItem = imports.find(_ => name === (_.as || _.name)) + if (!importItem) { + return this.error(`Cannot find import "${name}" to mock`) + } + mocks.push({ + name, + import: importItem, + factory: code.slice( + call.arguments[1].range![0], + call.arguments[1].range![1] + ), + start: call.range![0], + end: call.range![1], + }) + }, + }) + + if (!mocks.length) return + + const s = new MagicString(code) + + const mockMap = new Map() + for (const mock of mocks) { + s.overwrite(mock.start, mock.end, '') + if (!mockMap.has(mock.import.from)) { + mockMap.set(mock.import.from, []) + } + mockMap.get(mock.import.from)!.push(mock) + } + + const mockCode = [...mockMap.entries()] + .map(([from, mocks]) => { + const lines = [ + `vi.mock(${JSON.stringify(from)}, async () => {`, + ` const mod = { ...await vi.importActual(${JSON.stringify( + from + )}) }`, + ] + for (const mock of mocks) { + lines.push( + ` mod[${JSON.stringify(mock.name)}] = (${mock.factory})()` + ) + } + lines.push(` return mod`) + lines.push(`})`) + return lines.join('\n') + }) + .join('\n') + + s.append('\nimport {vi} from "vitest";\n' + mockCode) + + return { + code: s.toString(), + map: s.generateMap(), + } + }, + }) + }, +}) diff --git a/src/vitest-environment-nuxt/runtime/components/RouterLink.ts b/src/vitest-environment-nuxt/runtime/components/RouterLink.ts new file mode 100644 index 0000000000..67344908f8 --- /dev/null +++ b/src/vitest-environment-nuxt/runtime/components/RouterLink.ts @@ -0,0 +1,33 @@ +import { defineComponent, useRouter } from '#imports' + +export const RouterLink = defineComponent({ + functional: true, + props: { + to: String, + custom: Boolean, + replace: Boolean, + // Not implemented + activeClass: String, + exactActiveClass: String, + ariaCurrentValue: String, + }, + setup: (props, { slots }) => { + const navigate = () => {} + return () => { + const route = props.to ? useRouter().resolve(props.to) : {} + return props.custom + ? slots.default?.({ href: props.to, navigate, route }) + : h( + 'a', + { + href: props.to, + onClick: (e: MouseEvent) => { + e.preventDefault() + return navigate() + }, + }, + slots + ) + } + }, +}) diff --git a/src/vitest-environment-nuxt/runtime/mock.ts b/src/vitest-environment-nuxt/runtime/mock.ts new file mode 100644 index 0000000000..955dbd2fb6 --- /dev/null +++ b/src/vitest-environment-nuxt/runtime/mock.ts @@ -0,0 +1,18 @@ +import { defineEventHandler } from 'h3' +import type { EventHandler } from 'h3' + +export function registerEndpoint(url: string, handler: EventHandler) { + // @ts-expect-error private property + if (!window.__app) return + // @ts-expect-error private property + window.__app.use('/_' + url, defineEventHandler(handler)) + // @ts-expect-error private property + window.__registry.add(url) +} + +export function mockNuxtImport( + name: string, + factory: () => T | Promise +) { + throw new Error('mockNuxtImport() is a macro and it did not get transpiled') +} diff --git a/src/vitest-environment-nuxt/runtime/mount.ts b/src/vitest-environment-nuxt/runtime/mount.ts new file mode 100644 index 0000000000..e85d175ec2 --- /dev/null +++ b/src/vitest-environment-nuxt/runtime/mount.ts @@ -0,0 +1,32 @@ +import { mount, VueWrapper } from '@vue/test-utils' +import { h, DefineComponent, Suspense, nextTick } from 'vue' + +import { RouterLink } from './components/RouterLink' + +// @ts-expect-error virtual file +import NuxtRoot from '#build/root-component.mjs' + +export async function mountSuspended< + T extends DefineComponent +>(component: T) { + return new Promise>>(resolve => { + const vm = mount( + { + setup: NuxtRoot.setup, + render: () => + h( + Suspense, + { onResolve: () => nextTick().then(() => resolve(vm as any)) }, + { default: () => h(component) } + ), + }, + { + global: { + components: { + RouterLink, + }, + }, + } + ) + }) +} diff --git a/src/vitest-environment-nuxt/utils.ts b/src/vitest-environment-nuxt/utils.ts new file mode 100644 index 0000000000..faee86eab4 --- /dev/null +++ b/src/vitest-environment-nuxt/utils.ts @@ -0,0 +1,2 @@ +export { registerEndpoint, mockNuxtImport } from './runtime/mock' +export { mountSuspended } from './runtime/mount'