You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
464 lines
16 KiB
464 lines
16 KiB
import { existsSync, promises, readdirSync, writeFileSync } from 'node:fs'; |
|
import { resolve, relative } from 'pathe'; |
|
import { c as coverageConfigDefaults, r as resolveCoverageReporters, m as mm } from './chunks/resolveConfig.Dha6ilPI.js'; |
|
import c from 'tinyrainbow'; |
|
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/env.CmHVDJnw.js'; |
|
import 'std-env'; |
|
import '@vitest/runner/utils'; |
|
import './chunks/base.DwXGwWst.js'; |
|
import '@vitest/utils'; |
|
import 'node:crypto'; |
|
import './chunks/RandomSequencer.Bh5-tlNJ.js'; |
|
import 'node:perf_hooks'; |
|
import '@vitest/utils/source-map'; |
|
import 'tinyexec'; |
|
import 'path'; |
|
import 'fs'; |
|
import 'module'; |
|
import 'vite'; |
|
import 'vite-node/utils'; |
|
import './chunks/_commonjsHelpers.BFTU3MAI.js'; |
|
import 'util'; |
|
import 'node:events'; |
|
import 'tinypool'; |
|
import './chunks/index.BpSiYbpB.js'; |
|
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 };
|
|
|