@nuxt/test-utils
Version:
Test utilities for Nuxt
458 lines (452 loc) • 15.7 kB
JavaScript
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 };