UNPKG

@nuxt/test-utils

Version:
458 lines (452 loc) 15.7 kB
import { pathToFileURL } from 'node:url'; import { useNuxt, resolveIgnorePatterns, addVitePlugin, defineNuxtModule, createResolver, resolvePath, logger } from '@nuxt/kit'; import { mergeConfig } from 'vite'; 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.mjs'; import { walk } from 'estree-walker'; import MagicString from 'magic-string'; import { createUnplugin } from 'unplugin'; import { readFileSync } from 'node:fs'; import { extname, join, dirname } from 'pathe'; import 'c12'; import 'destr'; import 'scule'; const PLUGIN_NAME$1 = "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]; const createMockPlugin = (ctx) => createUnplugin(() => { function transform(code, id) { if (!HELPERS_NAME.some((n) => code.includes(n))) return; if (id.includes("/node_modules/")) return; let ast; try { ast = this.parse(code, { // @ts-expect-error compatibility with rollup v3 sourceType: "module", ecmaVersion: "latest", ranges: true }); } catch { return; } let insertionPoint = 0; let hasViImport = false; const s = new MagicString(code); const mocksImport = []; const mocksComponent = []; const importPathsList = /* @__PURE__ */ new Set(); walk(ast, { enter: (node, parent) => { if (isImportDeclaration(node)) { if (node.source.value === "vitest" && !hasViImport) { const viImport = node.specifiers.find( (i) => isImportSpecifier(i) && i.imported.type === "Identifier" && i.imported.name === "vi" ); if (viImport) { insertionPoint = endOf(node); hasViImport = true; } return; } } if (!isCallExpression(node)) return; if (isIdentifier(node.callee) && node.callee.name === HELPER_MOCK_IMPORT) { if (node.arguments.length !== 2) { return this.error( new Error( `${HELPER_MOCK_IMPORT}() should have exactly 2 arguments` ), startOf(node) ); } const importName = node.arguments[0]; if (!isLiteral(importName) || typeof importName.value !== "string") { return this.error( new Error( `The first argument of ${HELPER_MOCK_IMPORT}() must be a string literal` ), startOf(importName) ); } const name = importName.value; 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( isExpressionStatement(parent) ? startOf(parent) : startOf(node.arguments[0]), isExpressionStatement(parent) ? endOf(parent) : endOf(node.arguments[1]), "" ); mocksImport.push({ name, import: importItem, factory: code.slice( startOf(node.arguments[1]), endOf(node.arguments[1]) ) }); } if (isIdentifier(node.callee) && node.callee.name === HELPER_MOCK_COMPONENT) { if (node.arguments.length !== 2) { return this.error( new Error( `${HELPER_MOCK_COMPONENT}() should have exactly 2 arguments` ), startOf(node) ); } const componentName = node.arguments[0]; if (!isLiteral(componentName) || typeof componentName.value !== "string") { return this.error( new Error( `The first argument of ${HELPER_MOCK_COMPONENT}() must be a string literal` ), startOf(componentName) ); } const pathOrName = componentName.value; const component = ctx.components.find( (_) => _.pascalName === pathOrName || _.kebabName === pathOrName ); const path = component?.filePath || pathOrName; s.overwrite( isExpressionStatement(parent) ? startOf(parent) : startOf(node.arguments[1]), isExpressionStatement(parent) ? endOf(parent) : endOf(node.arguments[1]), "" ); mocksComponent.push({ path, factory: code.slice( startOf(node.arguments[1]), endOf(node.arguments[1]) ) }); } } }); if (mocksImport.length === 0 && mocksComponent.length === 0) return; const mockLines = []; if (mocksImport.length) { const mockImportMap = /* @__PURE__ */ 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 = 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})();` ); } 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.appendLeft(insertionPoint, ` vi.hoisted(() => { if(!globalThis.${HELPER_MOCK_HOIST}){ vi.stubGlobal(${JSON.stringify(HELPER_MOCK_HOIST)}, {}) } }); `); if (!hasViImport) s.prepend(`import {vi} from "vitest"; `); s.appendLeft(insertionPoint, "\n" + mockLines.join("\n") + "\n"); importPathsList.forEach((p) => { s.append(` import ${JSON.stringify(p)};`); }); return { code: s.toString(), map: s.generateMap() }; } return { name: PLUGIN_NAME$1, enforce: "post", vite: { transform, // Place Vitest's mock plugin after all Nuxt plugins async configResolved(config) { const plugins = config.plugins || []; const vitestPlugins = plugins.filter((p) => (p.name === "vite:mocks" || p.name.startsWith("vitest:")) && (p.enforce || "order" in p && p.order) === "post"); const lastNuxt = findLastIndex( plugins, (i) => i.name?.startsWith("nuxt:") ); 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); } } } } }; }); function findLastIndex(arr, predicate) { for (let i = arr.length - 1; i >= 0; i--) { if (predicate(arr[i])) return i; } return -1; } function isImportDeclaration(node) { return node.type === "ImportDeclaration"; } function isImportSpecifier(node) { return node.type === "ImportSpecifier"; } function isCallExpression(node) { return node.type === "CallExpression"; } function isIdentifier(node) { return node.type === "Identifier"; } function isLiteral(node) { return node.type === "Literal"; } function isExpressionStatement(node) { return node?.type === "ExpressionStatement"; } function startOf(node) { return "range" in node && node.range ? node.range[0] : "start" in node ? node.start : void 0; } function endOf(node) { return "range" in node && node.range ? node.range[1] : "end" in node ? node.end : void 0; } function setupImportMocking() { const nuxt = useNuxt(); const ctx = { components: [], imports: [] }; let importsCtx; nuxt.hook("imports:context", async (ctx2) => { importsCtx = ctx2; }); nuxt.hook("ready", async () => { ctx.imports = await importsCtx.getImports(); }); nuxt.hook("components:extend", (_) => { ctx.components = _; }); nuxt.hook("imports:sources", (presets) => { const idx = presets.findIndex((p) => p.imports.includes("setInterval")); if (idx !== -1) { presets.splice(idx, 1); } }); nuxt.options.ignore = nuxt.options.ignore.filter((i) => i !== "**/*.{spec,test}.{js,cts,mts,ts,jsx,tsx}"); if (nuxt._ignore) { for (const pattern of resolveIgnorePatterns("**/*.{spec,test}.{js,cts,mts,ts,jsx,tsx}")) { nuxt._ignore.add(`!${pattern}`); } } addVitePlugin(createMockPlugin(ctx).vite()); } const PLUGIN_NAME = "nuxt:vitest:nuxt-root-stub"; const STUB_ID = "nuxt-vitest-app-entry"; const NuxtRootStubPlugin = createUnplugin((options) => { const STUB_ID_WITH_EXT = STUB_ID + extname(options.entry); return { name: PLUGIN_NAME, enforce: "pre", vite: { async resolveId(id, importer) { if (id.endsWith(STUB_ID) || id.endsWith(STUB_ID_WITH_EXT)) { return importer?.endsWith("index.html") ? id : join(dirname(options.entry), STUB_ID_WITH_EXT); } }, async load(id) { if (id.endsWith(STUB_ID) || id.endsWith(STUB_ID_WITH_EXT)) { const entryContents = readFileSync(options.entry, "utf-8"); return entryContents.replace("#build/root-component.mjs", options.rootStubPath); } } } }; }); const vitePluginBlocklist = ["vite-plugin-vue-inspector", "vite-plugin-vue-inspector:post", "vite-plugin-inspect", "nuxt:type-check"]; const module = defineNuxtModule({ meta: { name: "@nuxt/test-utils", configKey: "testUtils" }, defaults: { startOnBoot: false, logToConsole: false }, async setup(options, nuxt) { if (nuxt.options.test || nuxt.options.dev) { 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.test && !nuxt.options.dev) { nuxt.options.vite.define ||= {}; nuxt.options.vite.define["import.meta.vitest"] = "undefined"; } nuxt.hook("prepare:types", ({ references }) => { references.push({ types: "vitest/import-meta" }); }); if (!nuxt.options.dev) return; if (process.env.TEST || process.env.VITE_TEST) return; const rawViteConfigPromise = new Promise((resolve) => { nuxt.hook("app:resolve", () => { nuxt.hook("vite:configResolved", (config, { isClient }) => { if (isClient) resolve(config); }); }); }); let loaded = false; let promise; let ctx = void 0; let testFiles = null; const updateTabs = debounce(() => { nuxt.callHook("devtools:customTabs:refresh"); }, 100); let URL; async function start() { const rawViteConfig = mergeConfig({}, await rawViteConfigPromise); const viteConfig = await getVitestConfigFromNuxt({ nuxt, viteConfig: defu({ test: options.vitestConfig }, rawViteConfig) }); viteConfig.plugins = (viteConfig.plugins || []).filter((p) => { return !p || !("name" in p) || !vitePluginBlocklist.includes(p.name); }); process.env.__NUXT_VITEST_RESOLVED__ = "true"; const { startVitest } = await import(pathToFileURL(await resolvePath("vitest/node")).href); const customReporter = { onInit(_ctx) { ctx = _ctx; }, onTaskUpdate() { testFiles = ctx.state.getFiles(); updateTabs(); }, onFinished() { testFiles = ctx.state.getFiles(); updateTabs(); } }; const watchMode = !process.env.NUXT_VITEST_DEV_TEST && !isCI; const PORT = await getPort({ port: 15555 }); const PROTOCOL = nuxt.options.devServer.https ? "https" : "http"; URL = `${PROTOCOL}://localhost:${PORT}/__vitest__/`; const overrides = watchMode ? { passWithNoTests: true, reporters: options.logToConsole ? [ ...toArray(options.vitestConfig?.reporters ?? ["default"]), customReporter ] : [customReporter], // do not report to console watch: true, ui: true, open: false, api: { port: PORT } } : { watch: false }; const promise2 = startVitest("test", [], defu(overrides, viteConfig.test), viteConfig); promise2.catch(() => process.exit(1)); if (watchMode) { logger.info(`Vitest UI starting on ${URL}`); nuxt.hook("close", () => promise2.then((v) => v?.close())); await new Promise((resolve) => setTimeout(resolve, 1e3)); } else { promise2.then((v) => nuxt.close().then(() => v?.close()).then(() => process.exit())); } loaded = true; } nuxt.hook("devtools:customTabs", (tabs) => { const failedCount = testFiles?.filter((f) => f.result?.state === "fail").length ?? 0; const passedCount = testFiles?.filter((f) => f.result?.state === "pass").length ?? 0; const totalCount = testFiles?.length ?? 0; tabs.push({ title: "Vitest", name: "vitest", icon: "logos-vitest", view: loaded ? { type: "iframe", src: URL } : { type: "launch", description: "Start tests along with Nuxt", actions: [ { label: promise ? "Starting..." : "Start Vitest", pending: !!promise, handle: () => { promise = promise || start(); return promise; } } ] }, extraTabVNode: totalCount ? h("div", { style: { color: failedCount ? "orange" : "green" } }, [ h("span", {}, passedCount), h("span", { style: { opacity: "0.5", fontSize: "0.9em" } }, "/"), h( "span", { style: { opacity: "0.8", fontSize: "0.9em" } }, totalCount ) ]) : void 0 }); }); if (options.startOnBoot) { promise = promise || start(); promise.then(updateTabs); } } }); function toArray(value) { return Array.isArray(value) ? value : [value]; } export { module as default };