462 lines
16 KiB
JavaScript
462 lines
16 KiB
JavaScript
import { existsSync, promises, readdirSync, writeFileSync } from 'node:fs';
|
|
import { c as coverageConfigDefaults, r as resolveCoverageReporters, m as mm } from './chunks/resolveConfig.rBxzbVsl.js';
|
|
import { resolve, relative } from 'pathe';
|
|
import c from 'tinyrainbow';
|
|
import 'node:crypto';
|
|
import '@vitest/utils';
|
|
import 'node:path';
|
|
import 'node:fs/promises';
|
|
import 'node:process';
|
|
import 'node:module';
|
|
import 'node:url';
|
|
import 'node:assert';
|
|
import 'node:v8';
|
|
import 'node:util';
|
|
import './chunks/constants.fzPh7AOq.js';
|
|
import 'node:os';
|
|
import './chunks/RandomSequencer.CMRlh2v4.js';
|
|
import 'std-env';
|
|
import 'node:perf_hooks';
|
|
import '@vitest/runner/utils';
|
|
import '@vitest/utils/source-map';
|
|
import 'tinyexec';
|
|
import 'vite';
|
|
import 'fs';
|
|
import 'vite-node/utils';
|
|
import './chunks/_commonjsHelpers.BFTU3MAI.js';
|
|
import 'util';
|
|
import 'path';
|
|
import 'node:events';
|
|
import './chunks/index.68735LiX.js';
|
|
import 'tinypool';
|
|
import 'node:worker_threads';
|
|
import './path.js';
|
|
|
|
const THRESHOLD_KEYS = [
|
|
"lines",
|
|
"functions",
|
|
"statements",
|
|
"branches"
|
|
];
|
|
const GLOBAL_THRESHOLDS_KEY = "global";
|
|
const DEFAULT_PROJECT = Symbol.for("default-project");
|
|
let uniqueId = 0;
|
|
class BaseCoverageProvider {
|
|
ctx;
|
|
name;
|
|
version;
|
|
options;
|
|
coverageFiles = /* @__PURE__ */ new Map();
|
|
pendingPromises = [];
|
|
coverageFilesDirectory;
|
|
_initialize(ctx) {
|
|
this.ctx = ctx;
|
|
if (ctx.version !== this.version) {
|
|
ctx.logger.warn(
|
|
c.yellow(
|
|
`Loaded ${c.inverse(c.yellow(` vitest@${ctx.version} `))} and ${c.inverse(c.yellow(` @vitest/coverage-${this.name}@${this.version} `))}.
|
|
Running mixed versions is not supported and may lead into bugs
|
|
Update your dependencies and make sure the versions match.`
|
|
)
|
|
);
|
|
}
|
|
const config = ctx.config.coverage;
|
|
this.options = {
|
|
...coverageConfigDefaults,
|
|
// User's options
|
|
...config,
|
|
// Resolved fields
|
|
provider: this.name,
|
|
reportsDirectory: resolve(
|
|
ctx.config.root,
|
|
config.reportsDirectory || coverageConfigDefaults.reportsDirectory
|
|
),
|
|
reporter: resolveCoverageReporters(
|
|
config.reporter || coverageConfigDefaults.reporter
|
|
),
|
|
thresholds: config.thresholds && {
|
|
...config.thresholds,
|
|
lines: config.thresholds["100"] ? 100 : config.thresholds.lines,
|
|
branches: config.thresholds["100"] ? 100 : config.thresholds.branches,
|
|
functions: config.thresholds["100"] ? 100 : config.thresholds.functions,
|
|
statements: config.thresholds["100"] ? 100 : config.thresholds.statements
|
|
}
|
|
};
|
|
const shard = this.ctx.config.shard;
|
|
const tempDirectory = `.tmp${shard ? `-${shard.index}-${shard.count}` : ""}`;
|
|
this.coverageFilesDirectory = resolve(
|
|
this.options.reportsDirectory,
|
|
tempDirectory
|
|
);
|
|
}
|
|
createCoverageMap() {
|
|
throw new Error("BaseReporter's createCoverageMap was not overwritten");
|
|
}
|
|
async generateReports(_, __) {
|
|
throw new Error("BaseReporter's generateReports was not overwritten");
|
|
}
|
|
async parseConfigModule(_) {
|
|
throw new Error("BaseReporter's parseConfigModule was not overwritten");
|
|
}
|
|
resolveOptions() {
|
|
return this.options;
|
|
}
|
|
async clean(clean = true) {
|
|
if (clean && existsSync(this.options.reportsDirectory)) {
|
|
await promises.rm(this.options.reportsDirectory, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10
|
|
});
|
|
}
|
|
if (existsSync(this.coverageFilesDirectory)) {
|
|
await promises.rm(this.coverageFilesDirectory, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10
|
|
});
|
|
}
|
|
await promises.mkdir(this.coverageFilesDirectory, { recursive: true });
|
|
this.coverageFiles = /* @__PURE__ */ new Map();
|
|
this.pendingPromises = [];
|
|
}
|
|
onAfterSuiteRun({ coverage, transformMode, projectName, testFiles }) {
|
|
if (!coverage) {
|
|
return;
|
|
}
|
|
if (transformMode !== "web" && transformMode !== "ssr" && transformMode !== "browser") {
|
|
throw new Error(`Invalid transform mode: ${transformMode}`);
|
|
}
|
|
let entry = this.coverageFiles.get(projectName || DEFAULT_PROJECT);
|
|
if (!entry) {
|
|
entry = { web: {}, ssr: {}, browser: {} };
|
|
this.coverageFiles.set(projectName || DEFAULT_PROJECT, entry);
|
|
}
|
|
const testFilenames = testFiles.join();
|
|
const filename = resolve(
|
|
this.coverageFilesDirectory,
|
|
`coverage-${uniqueId++}.json`
|
|
);
|
|
entry[transformMode][testFilenames] = filename;
|
|
const promise = promises.writeFile(filename, JSON.stringify(coverage), "utf-8");
|
|
this.pendingPromises.push(promise);
|
|
}
|
|
async readCoverageFiles({ onFileRead, onFinished, onDebug }) {
|
|
let index = 0;
|
|
const total = this.pendingPromises.length;
|
|
await Promise.all(this.pendingPromises);
|
|
this.pendingPromises = [];
|
|
for (const [projectName, coveragePerProject] of this.coverageFiles.entries()) {
|
|
for (const [transformMode, coverageByTestfiles] of Object.entries(coveragePerProject)) {
|
|
const filenames = Object.values(coverageByTestfiles);
|
|
const project = this.ctx.getProjectByName(projectName);
|
|
for (const chunk of this.toSlices(filenames, this.options.processingConcurrency)) {
|
|
if (onDebug.enabled) {
|
|
index += chunk.length;
|
|
onDebug("Covered files %d/%d", index, total);
|
|
}
|
|
await Promise.all(
|
|
chunk.map(async (filename) => {
|
|
const contents = await promises.readFile(filename, "utf-8");
|
|
const coverage = JSON.parse(contents);
|
|
onFileRead(coverage);
|
|
})
|
|
);
|
|
}
|
|
await onFinished(project, transformMode);
|
|
}
|
|
}
|
|
}
|
|
async cleanAfterRun() {
|
|
this.coverageFiles = /* @__PURE__ */ new Map();
|
|
await promises.rm(this.coverageFilesDirectory, { recursive: true });
|
|
if (readdirSync(this.options.reportsDirectory).length === 0) {
|
|
await promises.rm(this.options.reportsDirectory, { recursive: true });
|
|
}
|
|
}
|
|
async onTestFailure() {
|
|
if (!this.options.reportOnFailure) {
|
|
await this.cleanAfterRun();
|
|
}
|
|
}
|
|
async reportCoverage(coverageMap, { allTestsRun }) {
|
|
await this.generateReports(
|
|
coverageMap || this.createCoverageMap(),
|
|
allTestsRun
|
|
);
|
|
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch;
|
|
if (!keepResults) {
|
|
await this.cleanAfterRun();
|
|
}
|
|
}
|
|
async reportThresholds(coverageMap, allTestsRun) {
|
|
const resolvedThresholds = this.resolveThresholds(coverageMap);
|
|
this.checkThresholds(resolvedThresholds);
|
|
if (this.options.thresholds?.autoUpdate && allTestsRun) {
|
|
if (!this.ctx.server.config.configFile) {
|
|
throw new Error(
|
|
'Missing configurationFile. The "coverage.thresholds.autoUpdate" can only be enabled when configuration file is used.'
|
|
);
|
|
}
|
|
const configFilePath = this.ctx.server.config.configFile;
|
|
const configModule = await this.parseConfigModule(configFilePath);
|
|
await this.updateThresholds({
|
|
thresholds: resolvedThresholds,
|
|
configurationFile: configModule,
|
|
onUpdate: () => writeFileSync(
|
|
configFilePath,
|
|
configModule.generate().code,
|
|
"utf-8"
|
|
)
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Constructs collected coverage and users' threshold options into separate sets
|
|
* where each threshold set holds their own coverage maps. Threshold set is either
|
|
* for specific files defined by glob pattern or global for all other files.
|
|
*/
|
|
resolveThresholds(coverageMap) {
|
|
const resolvedThresholds = [];
|
|
const files = coverageMap.files();
|
|
const globalCoverageMap = this.createCoverageMap();
|
|
for (const key of Object.keys(this.options.thresholds)) {
|
|
if (key === "perFile" || key === "autoUpdate" || key === "100" || THRESHOLD_KEYS.includes(key)) {
|
|
continue;
|
|
}
|
|
const glob = key;
|
|
const globThresholds = resolveGlobThresholds(this.options.thresholds[glob]);
|
|
const globCoverageMap = this.createCoverageMap();
|
|
const matchingFiles = files.filter(
|
|
(file) => mm.isMatch(relative(this.ctx.config.root, file), glob)
|
|
);
|
|
for (const file of matchingFiles) {
|
|
const fileCoverage = coverageMap.fileCoverageFor(file);
|
|
globCoverageMap.addFileCoverage(fileCoverage);
|
|
}
|
|
resolvedThresholds.push({
|
|
name: glob,
|
|
coverageMap: globCoverageMap,
|
|
thresholds: globThresholds
|
|
});
|
|
}
|
|
for (const file of files) {
|
|
const fileCoverage = coverageMap.fileCoverageFor(file);
|
|
globalCoverageMap.addFileCoverage(fileCoverage);
|
|
}
|
|
resolvedThresholds.unshift({
|
|
name: GLOBAL_THRESHOLDS_KEY,
|
|
coverageMap: globalCoverageMap,
|
|
thresholds: {
|
|
branches: this.options.thresholds?.branches,
|
|
functions: this.options.thresholds?.functions,
|
|
lines: this.options.thresholds?.lines,
|
|
statements: this.options.thresholds?.statements
|
|
}
|
|
});
|
|
return resolvedThresholds;
|
|
}
|
|
/**
|
|
* Check collected coverage against configured thresholds. Sets exit code to 1 when thresholds not reached.
|
|
*/
|
|
checkThresholds(allThresholds) {
|
|
for (const { coverageMap, thresholds, name } of allThresholds) {
|
|
if (thresholds.branches === void 0 && thresholds.functions === void 0 && thresholds.lines === void 0 && thresholds.statements === void 0) {
|
|
continue;
|
|
}
|
|
const summaries = this.options.thresholds?.perFile ? coverageMap.files().map((file) => ({
|
|
file,
|
|
summary: coverageMap.fileCoverageFor(file).toSummary()
|
|
})) : [{ file: null, summary: coverageMap.getCoverageSummary() }];
|
|
for (const { summary, file } of summaries) {
|
|
for (const thresholdKey of THRESHOLD_KEYS) {
|
|
const threshold = thresholds[thresholdKey];
|
|
if (threshold !== void 0) {
|
|
const coverage = summary.data[thresholdKey].pct;
|
|
if (coverage < threshold) {
|
|
process.exitCode = 1;
|
|
let errorMessage = `ERROR: Coverage for ${thresholdKey} (${coverage}%) does not meet ${name === GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`} threshold (${threshold}%)`;
|
|
if (this.options.thresholds?.perFile && file) {
|
|
errorMessage += ` for ${relative("./", file).replace(/\\/g, "/")}`;
|
|
}
|
|
this.ctx.logger.error(errorMessage);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Check if current coverage is above configured thresholds and bump the thresholds if needed
|
|
*/
|
|
async updateThresholds({ thresholds: allThresholds, onUpdate, configurationFile }) {
|
|
let updatedThresholds = false;
|
|
const config = resolveConfig(configurationFile);
|
|
assertConfigurationModule(config);
|
|
for (const { coverageMap, thresholds, name } of allThresholds) {
|
|
const summaries = this.options.thresholds?.perFile ? coverageMap.files().map(
|
|
(file) => coverageMap.fileCoverageFor(file).toSummary()
|
|
) : [coverageMap.getCoverageSummary()];
|
|
const thresholdsToUpdate = [];
|
|
for (const key of THRESHOLD_KEYS) {
|
|
const threshold = thresholds[key] ?? 100;
|
|
const actual = Math.min(
|
|
...summaries.map((summary) => summary[key].pct)
|
|
);
|
|
if (actual > threshold) {
|
|
thresholdsToUpdate.push([key, actual]);
|
|
}
|
|
}
|
|
if (thresholdsToUpdate.length === 0) {
|
|
continue;
|
|
}
|
|
updatedThresholds = true;
|
|
for (const [threshold, newValue] of thresholdsToUpdate) {
|
|
if (name === GLOBAL_THRESHOLDS_KEY) {
|
|
config.test.coverage.thresholds[threshold] = newValue;
|
|
} else {
|
|
const glob = config.test.coverage.thresholds[name];
|
|
glob[threshold] = newValue;
|
|
}
|
|
}
|
|
}
|
|
if (updatedThresholds) {
|
|
this.ctx.logger.log("Updating thresholds to configuration file. You may want to push with updated coverage thresholds.");
|
|
onUpdate();
|
|
}
|
|
}
|
|
async mergeReports(coverageMaps) {
|
|
const coverageMap = this.createCoverageMap();
|
|
for (const coverage of coverageMaps) {
|
|
coverageMap.merge(coverage);
|
|
}
|
|
await this.generateReports(coverageMap, true);
|
|
}
|
|
hasTerminalReporter(reporters) {
|
|
return reporters.some(
|
|
([reporter]) => reporter === "text" || reporter === "text-summary" || reporter === "text-lcov" || reporter === "teamcity"
|
|
);
|
|
}
|
|
toSlices(array, size) {
|
|
return array.reduce((chunks, item) => {
|
|
const index = Math.max(0, chunks.length - 1);
|
|
const lastChunk = chunks[index] || [];
|
|
chunks[index] = lastChunk;
|
|
if (lastChunk.length >= size) {
|
|
chunks.push([item]);
|
|
} else {
|
|
lastChunk.push(item);
|
|
}
|
|
return chunks;
|
|
}, []);
|
|
}
|
|
createUncoveredFileTransformer(ctx) {
|
|
const servers = [
|
|
...ctx.projects.map((project) => ({
|
|
root: project.config.root,
|
|
vitenode: project.vitenode
|
|
})),
|
|
// Check core last as it will match all files anyway
|
|
{ root: ctx.config.root, vitenode: ctx.vitenode }
|
|
];
|
|
return async function transformFile(filename) {
|
|
let lastError;
|
|
for (const { root, vitenode } of servers) {
|
|
if (!filename.startsWith(root)) {
|
|
continue;
|
|
}
|
|
try {
|
|
return await vitenode.transformRequest(filename);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
throw lastError;
|
|
};
|
|
}
|
|
}
|
|
function resolveGlobThresholds(thresholds) {
|
|
if (!thresholds || typeof thresholds !== "object") {
|
|
return {};
|
|
}
|
|
if (100 in thresholds && thresholds[100] === true) {
|
|
return {
|
|
lines: 100,
|
|
branches: 100,
|
|
functions: 100,
|
|
statements: 100
|
|
};
|
|
}
|
|
return {
|
|
lines: "lines" in thresholds && typeof thresholds.lines === "number" ? thresholds.lines : void 0,
|
|
branches: "branches" in thresholds && typeof thresholds.branches === "number" ? thresholds.branches : void 0,
|
|
functions: "functions" in thresholds && typeof thresholds.functions === "number" ? thresholds.functions : void 0,
|
|
statements: "statements" in thresholds && typeof thresholds.statements === "number" ? thresholds.statements : void 0
|
|
};
|
|
}
|
|
function assertConfigurationModule(config) {
|
|
try {
|
|
if (typeof config.test.coverage.thresholds !== "object") {
|
|
throw new TypeError(
|
|
"Expected config.test.coverage.thresholds to be an object"
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(
|
|
`Unable to parse thresholds from configuration file: ${message}`
|
|
);
|
|
}
|
|
}
|
|
function resolveConfig(configModule) {
|
|
const mod = configModule.exports.default;
|
|
try {
|
|
if (mod.$type === "object") {
|
|
return mod;
|
|
}
|
|
let config = resolveDefineConfig(mod);
|
|
if (config) {
|
|
return config;
|
|
}
|
|
if (mod.$type === "function-call" && mod.$callee === "mergeConfig") {
|
|
config = resolveMergeConfig(mod);
|
|
if (config) {
|
|
return config;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
throw new Error(error instanceof Error ? error.message : String(error));
|
|
}
|
|
throw new Error(
|
|
"Failed to update coverage thresholds. Configuration file is too complex."
|
|
);
|
|
}
|
|
function resolveDefineConfig(mod) {
|
|
if (mod.$type === "function-call" && mod.$callee === "defineConfig") {
|
|
if (mod.$args[0].$type === "object") {
|
|
return mod.$args[0];
|
|
}
|
|
if (mod.$args[0].$type === "arrow-function-expression") {
|
|
if (mod.$args[0].$body.$type === "object") {
|
|
return mod.$args[0].$body;
|
|
}
|
|
const config = resolveMergeConfig(mod.$args[0].$body);
|
|
if (config) {
|
|
return config;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function resolveMergeConfig(mod) {
|
|
if (mod.$type === "function-call" && mod.$callee === "mergeConfig") {
|
|
for (const arg of mod.$args) {
|
|
const config = resolveDefineConfig(arg);
|
|
if (config) {
|
|
return config;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export { BaseCoverageProvider };
|