vite-plugin-istanbul
Version:
vite-plugin-istanbul ==========================
233 lines (232 loc) • 7.72 kB
JavaScript
import { loadNycConfig } from "@istanbuljs/load-nyc-config";
import { createInstrumenter } from "istanbul-lib-instrument";
import picocolors from "picocolors";
import TestExclude from "test-exclude";
import { createLogger } from "vite";
import * as espree from "espree";
import { SourceMapGenerator } from "source-map";
//#region src/source-map.ts
function createIdentitySourceMap(file, source, option) {
const gen = new SourceMapGenerator(option);
espree.tokenize(source, {
loc: true,
ecmaVersion: "latest"
}).forEach((token) => {
const loc = token.loc.start;
gen.addMapping({
source: file,
original: loc,
generated: loc
});
});
return JSON.parse(gen.toString());
}
function createCompleteSourceMap(file, source, originalSource, option) {
const gen = new SourceMapGenerator(option);
const lines = source.split("\n");
const originalLines = originalSource ? originalSource.split("\n").length : lines.length;
lines.forEach((line, lineIndex) => {
const tokens = [];
try {
tokens.push(...espree.tokenize(line, {
loc: true,
ecmaVersion: "latest"
}));
} catch {
tokens.push({ loc: { start: {
line: 1,
column: 0
} } });
}
tokens.forEach((token) => {
const originalLine = Math.min(lineIndex + 1, originalLines);
gen.addMapping({
source: file,
original: {
line: originalLine,
column: 0
},
generated: {
line: lineIndex + 1,
column: token.loc.start.column
}
});
});
});
if (originalSource) gen.setSourceContent(file, originalSource);
return JSON.parse(gen.toString());
}
//#endregion
//#region src/vue-sfc.ts
/** # Fix for vue Single-File Components instrumentation in build mode. (cf issue #96)
*
* ## Option API SFC splits file into 2
*
* 1. id: /path/to/file.vue which contains all the code **What we need to instrument**
* 2. id: /path/to/file.vue?vue&type=style&... which contains no source code
*
* ## Composition API SFC splits file into 3 chunks
*
* 1. id: /path/to/file.vue which contains only impors and exports but no user's source code
* 2. id: /path/to/file.vue?vue&type=style&... which contains no source code
* 3. id: /path/to/file.vue?vue&type=script&... which contains all the user's source code **What we need to instrument**
*
* ## Diff of chunk 1
*
* - Composition API: starts with `import _sfc_main from '/path/to/file.vue?vue&type=script...'\n`
* - Option API: starts with `\nconst _sfc_main = {\n`
*
*/
function canInstrumentChunk(id, srcCode) {
const is1stChunk = id.endsWith(".vue");
const is2ndChunk = /\?vue&type=style/.test(id);
const is3rdChunk = /\?vue&type=script/.test(id);
const isCompositionAPI = /import _sfc_main from/.test(srcCode);
if (is2ndChunk) return false;
if (is3rdChunk) return true;
if (is1stChunk) return !isCompositionAPI;
return true;
}
//#endregion
//#region src/index.ts
const { yellow } = picocolors;
const DEFAULT_EXTENSION = [
".js",
".cjs",
".mjs",
".ts",
".tsx",
".jsx",
".vue"
];
const COVERAGE_PUBLIC_PATH = "/__coverage__";
const PLUGIN_NAME = "vite:istanbul";
const MODULE_PREFIX = "/@modules/";
const NULL_STRING = "\0";
function sanitizeSourceMap(rawSourceMap) {
const { sourcesContent, ...sourceMap } = rawSourceMap;
return JSON.parse(JSON.stringify(sourceMap));
}
function getEnvVariable(key, prefix, env) {
if (Array.isArray(prefix)) prefix = prefix.find((pre) => {
return env[`${pre}${key}`] != null;
}) ?? "";
return env[`${prefix}${key}`];
}
async function createTestExclude(opts) {
const { nycrcPath, include, exclude, extension } = opts;
const cwd = opts.cwd ?? process.cwd();
const nycConfig = await loadNycConfig({
cwd,
nycrcPath
});
return new TestExclude({
cwd,
include: include ?? nycConfig.include,
exclude: exclude ?? nycConfig.exclude,
extension: extension ?? nycConfig.extension ?? DEFAULT_EXTENSION,
excludeNodeModules: true
});
}
function resolveFilename(id) {
const [filename] = id.split("?vue");
return filename;
}
function istanbulPlugin(opts = {}) {
const requireEnv = opts?.requireEnv ?? false;
const checkProd = opts?.checkProd ?? true;
const forceBuildInstrument = opts?.forceBuildInstrument ?? false;
const logger = createLogger("warn", { prefix: "vite-plugin-istanbul" });
let testExclude;
const instrumenter = opts.instrumenter ?? createInstrumenter({
coverageGlobalScopeFunc: false,
coverageGlobalScope: "globalThis",
preserveComments: true,
produceSourceMap: true,
autoWrap: true,
esModules: true,
compact: false,
generatorOpts: { ...opts?.generatorOpts }
});
let enabled = true;
return {
name: PLUGIN_NAME,
apply(_, env) {
return forceBuildInstrument ? true : env.command == "serve";
},
enforce: "post",
async config(config) {
if (!config.build?.sourcemap) {
logger.warn(`${PLUGIN_NAME}> ${yellow(`Sourcemaps was automatically enabled for code coverage to be accurate.
To hide this message set build.sourcemap to true, 'inline' or 'hidden'.`)}`);
config.build ??= {};
config.build.sourcemap = true;
}
testExclude = await createTestExclude(opts);
},
configResolved(config) {
const { isProduction, env } = config;
const { CYPRESS_COVERAGE } = process.env;
const envPrefix = config.envPrefix ?? "VITE_";
const envVar = (opts.cypress ? CYPRESS_COVERAGE : getEnvVariable("COVERAGE", envPrefix, env))?.toLowerCase() ?? "";
if (checkProd && isProduction && !forceBuildInstrument || !requireEnv && envVar === "false" || requireEnv && envVar !== "true") enabled = false;
},
configureServer({ middlewares }) {
if (!enabled) return;
middlewares.use((req, res, next) => {
if (req.url !== COVERAGE_PUBLIC_PATH) return next();
const coverage = global.__coverage__ ?? null;
let data;
try {
data = JSON.stringify(coverage, null, 4);
} catch (ex) {
return next(ex);
}
res.setHeader("Content-Type", "application/json");
res.statusCode = 200;
res.end(data);
});
},
transform(srcCode, id, options) {
if (!enabled || options?.ssr || id.startsWith(MODULE_PREFIX) || id.startsWith(NULL_STRING)) return;
if (!canInstrumentChunk(id, srcCode)) return;
const filename = resolveFilename(id);
if (testExclude.shouldInstrument(filename)) {
const rawCombinedSourceMap = this.getCombinedSourcemap();
const combinedSourceMap = sanitizeSourceMap(rawCombinedSourceMap);
if (id.endsWith(".vue") || /\?vue&type=script/.test(id)) {
let originalSource = null;
if (rawCombinedSourceMap.sourcesContent && rawCombinedSourceMap.sourcesContent[0]) originalSource = rawCombinedSourceMap.sourcesContent[0];
const completeSourceMap = sanitizeSourceMap(createCompleteSourceMap(filename, srcCode, originalSource, {
file: combinedSourceMap.file,
sourceRoot: combinedSourceMap.sourceRoot
}));
const code = instrumenter.instrumentSync(srcCode, filename, completeSourceMap);
const map = instrumenter.lastSourceMap();
const fileCoverage = instrumenter.fileCoverage;
if (opts.onCover) opts.onCover(filename, fileCoverage);
return {
code,
map
};
}
const code = instrumenter.instrumentSync(srcCode, filename, combinedSourceMap);
const identitySourceMap = sanitizeSourceMap(createIdentitySourceMap(filename, srcCode, {
file: combinedSourceMap.file,
sourceRoot: combinedSourceMap.sourceRoot
}));
instrumenter.instrumentSync(srcCode, filename, identitySourceMap);
const map = instrumenter.lastSourceMap();
const fileCoverage = instrumenter.fileCoverage;
if (opts.onCover) opts.onCover(filename, fileCoverage);
return {
code,
map
};
}
}
};
}
//#endregion
export { istanbulPlugin as default };
//# sourceMappingURL=index.mjs.map