diff --git a/package.json b/package.json index 35997051c..3bd55acb8 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "std-env": "^3.5.0", "ufo": "^1.3.2", "unenv": "^1.8.0", + "unplugin": "^1.5.1", "vitest-environment-nuxt": "0.12.0" }, "devDependencies": { @@ -66,6 +67,7 @@ "@vitejs/plugin-vue": "4.5.0", "@vitejs/plugin-vue-jsx": "3.1.0", "@vue/test-utils": "2.4.3", + "acorn": "^8.11.2", "changelogen": "0.5.5", "eslint": "8.54.0", "eslint-plugin-import": "2.29.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2b157629..25fa2617a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: unenv: specifier: ^1.8.0 version: 1.8.0 + unplugin: + specifier: ^1.5.1 + version: 1.5.1 vitest-environment-nuxt: specifier: workspace:* version: link:stubs/vitest-environment-nuxt @@ -109,6 +112,9 @@ importers: '@vue/test-utils': specifier: 2.4.3 version: 2.4.3(vue@3.3.8) + acorn: + specifier: ^8.11.2 + version: 8.11.2 changelogen: specifier: 0.5.5 version: 0.5.5 diff --git a/src/module/mock.ts b/src/module/mock.ts index f7148f013..8cebb0625 100644 --- a/src/module/mock.ts +++ b/src/module/mock.ts @@ -1,31 +1,8 @@ -import type { Import, Unimport } from 'unimport' +import type { Unimport } from 'unimport' import { addVitePlugin, useNuxt } from '@nuxt/kit' -import { walk } from 'estree-walker' -import type { CallExpression } from 'estree' -import type { AcornNode } from 'rollup' -import MagicString from 'magic-string' -import type { Component } from '@nuxt/schema' -import type { Plugin } from 'vite' -import { normalize, resolve } from 'node:path' -const PLUGIN_NAME = 'nuxt:vitest:mock-transform' - -const HELPER_MOCK_IMPORT = 'mockNuxtImport' -const HELPER_MOCK_COMPONENT = 'mockComponent' -const HELPER_MOCK_HOIST = '__NUXT_VITEST_MOCKS' - -const HELPERS_NAME = [HELPER_MOCK_IMPORT, HELPER_MOCK_COMPONENT] - -export interface MockImportInfo { - name: string - import: Import - factory: string -} - -export interface MockComponentInfo { - path: string - factory: string -} +import { createMockPlugin } from './plugins/mock' +import type { MockPluginContext } from './plugins/mock' /** * This module is a macro that transforms `mockNuxtImport()` to `vi.mock()`, @@ -33,293 +10,23 @@ export interface MockComponentInfo { */ export function setupImportMocking () { const nuxt = useNuxt() + + const ctx: MockPluginContext = { + components: [], + imports: [] + } let importsCtx: Unimport - let imports: Import[] = [] - let components: Component[] = [] - nuxt.hook('imports:context', async ctx => { importsCtx = ctx }) - nuxt.hook('components:extend', _ => { - components = _ - }) nuxt.hook('ready', async () => { - imports = await importsCtx.getImports() + ctx.imports = await importsCtx.getImports() }) - // Polyfill Array.prototype.findLastIndex for legacy Node.js - function findLastIndex(arr: T[], predicate: (item: T) => boolean) { - for (let i = arr.length - 1; i >= 0; i--) { - if (predicate(arr[i])) return i - } - return -1 - } - - // path of the first vitest setup file to be ran - let resolvedFirstSetupFile: null | string = null - addVitePlugin({ - name: PLUGIN_NAME, - enforce: 'post', - // Place Vitest's mock plugin after all Nuxt plugins - configResolved(config) { - const firstSetupFile = Array.isArray(config.test?.setupFiles) - ? config.test!.setupFiles[0] - : config.test?.setupFiles - - if (firstSetupFile) { - resolvedFirstSetupFile = 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 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) - } - } - }, - transform: { - handler(code, id) { - const isFirstSetupFile = normalize(id) === resolvedFirstSetupFile - const shouldPrependMockHoist = resolvedFirstSetupFile - ? isFirstSetupFile - : true - - if (!HELPERS_NAME.some(n => code.includes(n))) return - if (id.includes('/node_modules/')) return - - let ast: AcornNode - try { - ast = this.parse(code, { - sourceType: 'module', - ecmaVersion: 'latest', - ranges: true, - }) - } catch (e) { - return - } - - let insertionPoint = 0 - let hasViImport = false - - const s = new MagicString(code) - const mocksImport: MockImportInfo[] = [] - const mocksComponent: MockComponentInfo[] = [] - const importPathsList: Set = new Set() - - walk(ast as any, { - enter: (node, parent) => { - // find existing vi import - if (node.type === 'ImportDeclaration') { - if (node.source.value === 'vitest' && !hasViImport) { - if ( - node.specifiers.find( - i => - i.type === 'ImportSpecifier' && i.imported.name === 'vi' - ) - ) { - insertionPoint = node.range![1] - hasViImport = true - } - return - } - } - - if (node.type !== 'CallExpression') return - const call = node as CallExpression - // mockNuxtImport - if ( - call.callee.type === 'Identifier' && - call.callee.name === HELPER_MOCK_IMPORT - ) { - if (call.arguments.length !== 2) { - return this.error( - new Error( - `${HELPER_MOCK_IMPORT}() should have exactly 2 arguments` - ), - call.range![0] - ) - } - if (call.arguments[0].type !== 'Literal') { - return this.error( - new Error( - `The first argument of ${HELPER_MOCK_IMPORT}() must be a string literal` - ), - call.arguments[0].range![0] - ) - } - const name = call.arguments[0].value as string - const importItem = imports.find(_ => name === (_.as || _.name)) - if (!importItem) { - console.log({ imports }) - return this.error(`Cannot find import "${name}" to mock`) - } - - s.overwrite( - parent?.type === 'ExpressionStatement' - ? parent.range![0] - : call.arguments[0].range![0], - parent?.type === 'ExpressionStatement' - ? parent.range![1] - : call.arguments[1].range![1], - '' - ) - mocksImport.push({ - name, - import: importItem, - factory: code.slice( - call.arguments[1].range![0], - call.arguments[1].range![1] - ), - }) - } - // mockComponent - if ( - call.callee.type === 'Identifier' && - call.callee.name === HELPER_MOCK_COMPONENT - ) { - if (call.arguments.length !== 2) { - return this.error( - new Error( - `${HELPER_MOCK_COMPONENT}() should have exactly 2 arguments` - ), - call.range![0] - ) - } - if (call.arguments[0].type !== 'Literal') { - return this.error( - new Error( - `The first argument of ${HELPER_MOCK_COMPONENT}() must be a string literal` - ), - call.arguments[0].range![0] - ) - } - const pathOrName = call.arguments[0].value as string - const component = components.find( - _ => _.pascalName === pathOrName || _.kebabName === pathOrName - ) - const path = component?.filePath || pathOrName - - s.overwrite( - parent?.type === 'ExpressionStatement' - ? parent.range![0] - : call.arguments[1].range![0], - parent?.type === 'ExpressionStatement' - ? parent.range![1] - : call.arguments[1].range![1], - '' - ) - mocksComponent.push({ - path: path, - factory: code.slice( - call.arguments[1].range![0], - call.arguments[1].range![1] - ), - }) - } - }, - }) - - if (mocksImport.length === 0 && mocksComponent.length === 0) return - - const mockLines = [] - - if (mocksImport.length) { - const mockImportMap = new Map() - for (const mock of mocksImport) { - if (!mockImportMap.has(mock.import.from)) { - mockImportMap.set(mock.import.from, []) - } - mockImportMap.get(mock.import.from)!.push(mock) - } - mockLines.push( - ...Array.from(mockImportMap.entries()).flatMap( - ([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 - )}) } }`, - ] - for (const mock of mocks) { - if (mock.import.name === 'default') { - lines.push( - ` mocks[${JSON.stringify(from)}]["default"] = await (${ - mock.factory - })()` - ) - } else { - lines.push( - ` mocks[${JSON.stringify(from)}][${JSON.stringify( - mock.name - )}] = await (${mock.factory})()` - ) - } - } - lines.push(` return mocks[${JSON.stringify(from)}] `) - lines.push(`})`) - return lines - } - ) - ) - } - - if (mocksComponent.length) { - mockLines.push( - ...mocksComponent.flatMap(mock => { - return [ - `vi.mock(${JSON.stringify(mock.path)}, async () => {`, - ` const factory = (${mock.factory});`, - ` const result = typeof factory === 'function' ? await factory() : await factory`, - ` return 'default' in result ? result : { default: result }`, - '})', - ] - }) - ) - } - - if (!mockLines.length) return - - s.prepend(`vi.hoisted(() => { - if(!global.${HELPER_MOCK_HOIST}){ - vi.stubGlobal(${JSON.stringify(HELPER_MOCK_HOIST)}, {}) - } - });\n`) - - if (!hasViImport) s.prepend(`import {vi} from "vitest";\n`) - - s.appendLeft(insertionPoint, mockLines.join('\n') + '\n') - - // do an import to trick vite to keep it - // if not, the module won't be mocked - if (shouldPrependMockHoist) { - importPathsList.forEach(p => { - s.append(`\n import ${JSON.stringify(p)};`) - }) - } - - return { - code: s.toString(), - map: s.generateMap(), - } - }, - }, + nuxt.hook('components:extend', _ => { + ctx.components = _ }) + + addVitePlugin(createMockPlugin(ctx).vite()) } diff --git a/src/module/plugins/mock.ts b/src/module/plugins/mock.ts new file mode 100644 index 000000000..310636237 --- /dev/null +++ b/src/module/plugins/mock.ts @@ -0,0 +1,313 @@ +import type { Import } from 'unimport' +import { walk } from 'estree-walker' +import type { CallExpression } from 'estree' +import type { AcornNode } from 'rollup' +import MagicString from 'magic-string' +import type { Component } from '@nuxt/schema' +import type { Plugin } from 'vite' +import { normalize, resolve } from 'node:path' +import { createUnplugin } from 'unplugin' + +export interface MockPluginContext { + imports: Import[] + components: Component[] +} + +const PLUGIN_NAME = 'nuxt:vitest:mock-transform' + +const HELPER_MOCK_IMPORT = 'mockNuxtImport' +const HELPER_MOCK_COMPONENT = 'mockComponent' +const HELPER_MOCK_HOIST = '__NUXT_VITEST_MOCKS' + +const HELPERS_NAME = [HELPER_MOCK_IMPORT, HELPER_MOCK_COMPONENT] + +export interface MockImportInfo { + name: string + import: Import + factory: string +} + +export interface MockComponentInfo { + path: string + factory: string +} + +export const createMockPlugin = (ctx: MockPluginContext) => createUnplugin(() => { + // path of the first vitest setup file to be ran + let resolvedFirstSetupFile: null | string = null + + return { + name: PLUGIN_NAME, + enforce: 'post', + vite: { + // Place Vitest's mock plugin after all Nuxt plugins + configResolved(config) { + const firstSetupFile = Array.isArray(config.test?.setupFiles) + ? config.test!.setupFiles[0] + : config.test?.setupFiles + + if (firstSetupFile) { + resolvedFirstSetupFile = 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 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) + } + } + }, + transform: { + handler(code, id) { + const isFirstSetupFile = normalize(id) === resolvedFirstSetupFile + const shouldPrependMockHoist = resolvedFirstSetupFile + ? isFirstSetupFile + : true + + if (!HELPERS_NAME.some(n => code.includes(n))) return + if (id.includes('/node_modules/')) return + + let ast: AcornNode + try { + ast = this.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + ranges: true, + }) + } catch (e) { + return + } + + let insertionPoint = 0 + let hasViImport = false + + const s = new MagicString(code) + const mocksImport: MockImportInfo[] = [] + const mocksComponent: MockComponentInfo[] = [] + const importPathsList: Set = new Set() + + walk(ast as any, { + enter: (node, parent) => { + // find existing vi import + if (node.type === 'ImportDeclaration') { + if (node.source.value === 'vitest' && !hasViImport) { + if ( + node.specifiers.find( + i => + i.type === 'ImportSpecifier' && i.imported.name === 'vi' + ) + ) { + insertionPoint = node.range![1] + hasViImport = true + } + return + } + } + + if (node.type !== 'CallExpression') return + const call = node as CallExpression + // mockNuxtImport + if ( + call.callee.type === 'Identifier' && + call.callee.name === HELPER_MOCK_IMPORT + ) { + if (call.arguments.length !== 2) { + return this.error( + new Error( + `${HELPER_MOCK_IMPORT}() should have exactly 2 arguments` + ), + call.range![0] + ) + } + if (call.arguments[0].type !== 'Literal') { + return this.error( + new Error( + `The first argument of ${HELPER_MOCK_IMPORT}() must be a string literal` + ), + call.arguments[0].range![0] + ) + } + const name = call.arguments[0].value as string + const importItem = ctx.imports.find(_ => name === (_.as || _.name)) + if (!importItem) { + console.log({ imports: ctx.imports }) + return this.error(`Cannot find import "${name}" to mock`) + } + + s.overwrite( + parent?.type === 'ExpressionStatement' + ? parent.range![0] + : call.arguments[0].range![0], + parent?.type === 'ExpressionStatement' + ? parent.range![1] + : call.arguments[1].range![1], + '' + ) + mocksImport.push({ + name, + import: importItem, + factory: code.slice( + call.arguments[1].range![0], + call.arguments[1].range![1] + ), + }) + } + // mockComponent + if ( + call.callee.type === 'Identifier' && + call.callee.name === HELPER_MOCK_COMPONENT + ) { + if (call.arguments.length !== 2) { + return this.error( + new Error( + `${HELPER_MOCK_COMPONENT}() should have exactly 2 arguments` + ), + call.range![0] + ) + } + if (call.arguments[0].type !== 'Literal') { + return this.error( + new Error( + `The first argument of ${HELPER_MOCK_COMPONENT}() must be a string literal` + ), + call.arguments[0].range![0] + ) + } + const pathOrName = call.arguments[0].value as string + const component = ctx.components.find( + _ => _.pascalName === pathOrName || _.kebabName === pathOrName + ) + const path = component?.filePath || pathOrName + + s.overwrite( + parent?.type === 'ExpressionStatement' + ? parent.range![0] + : call.arguments[1].range![0], + parent?.type === 'ExpressionStatement' + ? parent.range![1] + : call.arguments[1].range![1], + '' + ) + mocksComponent.push({ + path: path, + factory: code.slice( + call.arguments[1].range![0], + call.arguments[1].range![1] + ), + }) + } + }, + }) + + if (mocksImport.length === 0 && mocksComponent.length === 0) return + + const mockLines = [] + + if (mocksImport.length) { + const mockImportMap = new Map() + for (const mock of mocksImport) { + if (!mockImportMap.has(mock.import.from)) { + mockImportMap.set(mock.import.from, []) + } + mockImportMap.get(mock.import.from)!.push(mock) + } + mockLines.push( + ...Array.from(mockImportMap.entries()).flatMap( + ([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 + )}) } }`, + ] + for (const mock of mocks) { + if (mock.import.name === 'default') { + lines.push( + ` mocks[${JSON.stringify(from)}]["default"] = await (${ + mock.factory + })()` + ) + } else { + lines.push( + ` mocks[${JSON.stringify(from)}][${JSON.stringify( + mock.name + )}] = await (${mock.factory})()` + ) + } + } + lines.push(` return mocks[${JSON.stringify(from)}] `) + lines.push(`})`) + return lines + } + ) + ) + } + + if (mocksComponent.length) { + mockLines.push( + ...mocksComponent.flatMap(mock => { + return [ + `vi.mock(${JSON.stringify(mock.path)}, async () => {`, + ` const factory = (${mock.factory});`, + ` const result = typeof factory === 'function' ? await factory() : await factory`, + ` return 'default' in result ? result : { default: result }`, + '})', + ] + }) + ) + } + + if (!mockLines.length) return + + s.prepend(`vi.hoisted(() => { + if(!global.${HELPER_MOCK_HOIST}){ + vi.stubGlobal(${JSON.stringify(HELPER_MOCK_HOIST)}, {}) + } + });\n`) + + if (!hasViImport) s.prepend(`import {vi} from "vitest";\n`) + + s.appendLeft(insertionPoint, mockLines.join('\n') + '\n') + + // do an import to trick vite to keep it + // if not, the module won't be mocked + if (shouldPrependMockHoist) { + importPathsList.forEach(p => { + s.append(`\n import ${JSON.stringify(p)};`) + }) + } + + return { + code: s.toString(), + map: s.generateMap(), + } + }, + } + } + } +}) + +// Polyfill Array.prototype.findLastIndex for legacy Node.js +function findLastIndex(arr: T[], predicate: (item: T) => boolean) { + for (let i = arr.length - 1; i >= 0; i--) { + if (predicate(arr[i])) return i + } + return -1 +} diff --git a/test/unit/mock-transform.spec.ts b/test/unit/mock-transform.spec.ts new file mode 100644 index 000000000..f509bc0d9 --- /dev/null +++ b/test/unit/mock-transform.spec.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { type MockPluginContext, createMockPlugin } from "../../src/module/plugins/mock" +import { parse } from 'acorn' + +describe('mocking', () => { + const pluginContext: MockPluginContext = { imports: [], components: [] } + const plugin = createMockPlugin(pluginContext) + const getResult = (code: string): undefined | string => + (plugin.raw as any)().vite.transform.handler.call({ parse }, code, '/some/file.ts')?.code + + beforeEach(() => { + pluginContext.components = [] + pluginContext.imports = [] + }) + + describe('import mocking', () => { + it('should transform code with mocked imports', () => { + pluginContext.imports = [{ + name: 'useSomeExport', + from: 'bob' + }] + expect(getResult(` + import { mockNuxtImport } from '@nuxt/test-utils/runtime-utils' + mockNuxtImport('useSomeExport', () => { + return () => 'mocked' + }) + `)).toMatchInlineSnapshot(` + "import {vi} from \\"vitest\\"; + vi.hoisted(() => { + if(!global.__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\\") } } + mocks[\\"bob\\"][\\"useSomeExport\\"] = await (() => { + return () => 'mocked' + })() + return mocks[\\"bob\\"] + }) + + import { mockNuxtImport } from '@nuxt/test-utils/runtime-utils' + + + import \\"bob\\";" + `) + }) + it('should not add `vi` import if it already exists', () => { + pluginContext.imports = [{ + name: 'useSomeExport', + from: 'bob' + }] + const code = getResult(` + import { expect, vi } from 'vitest' + mockNuxtImport('useSomeExport', () => 'bob') + `) + expect(code).not.toContain('import {vi} from "vitest";') + }) + }) + + describe('component mocking', () => { + it('should work', () => { + pluginContext.components = [{ + chunkName: 'Thing', + export: 'default', + kebabName: 'thing', + pascalName: 'Thing', + prefetch: false, + preload: false, + shortPath: 'thing.vue', + filePath: '/test/thing.vue' + }] + expect(getResult(` + import { mockComponent } from '@nuxt/test-utils/runtime-utils' + mockComponent('MyComponent', () => import('./MockComponent.vue')) + `)).toMatchInlineSnapshot(` + "import {vi} from \\"vitest\\"; + vi.hoisted(() => { + if(!global.__NUXT_VITEST_MOCKS){ + vi.stubGlobal(\\"__NUXT_VITEST_MOCKS\\", {}) + } + }); + vi.mock(\\"MyComponent\\", async () => { + const factory = (() => import('./MockComponent.vue')); + const result = typeof factory === 'function' ? await factory() : await factory + return 'default' in result ? result : { default: result } + }) + + import { mockComponent } from '@nuxt/test-utils/runtime-utils' + + " + `) + }) + }) +})