diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index b0443dd6a4eed..c930020eaab7f 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -65,10 +65,6 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co provider: 'istanbul', reportsDirectory: resolve(ctx.config.root, config.reportsDirectory || coverageConfigDefaults.reportsDirectory), reporter: this.resolveReporters(config.reporter || coverageConfigDefaults.reporter), - lines: config['100'] ? 100 : config.lines, - functions: config['100'] ? 100 : config.functions, - branches: config['100'] ? 100 : config.branches, - statements: config['100'] ? 100 : config.statements, } this.instrumenter = createInstrumenter({ @@ -170,34 +166,25 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co }).execute(context) } - if (this.options.branches - || this.options.functions - || this.options.lines - || this.options.statements) { - this.checkThresholds({ + if (this.options.thresholds) { + const resolvedThresholds = this.resolveThresholds({ coverageMap, - thresholds: { - branches: this.options.branches, - functions: this.options.functions, - lines: this.options.lines, - statements: this.options.statements, - }, - perFile: this.options.perFile, + thresholds: this.options.thresholds, + createCoverageMap: () => libCoverage.createCoverageMap({}), }) - } - if (this.options.thresholdAutoUpdate && allTestsRun) { - this.updateThresholds({ - coverageMap, - thresholds: { - branches: this.options.branches, - functions: this.options.functions, - lines: this.options.lines, - statements: this.options.statements, - }, - perFile: this.options.perFile, - configurationFile: this.ctx.server.config.configFile, + this.checkThresholds({ + thresholds: resolvedThresholds, + perFile: this.options.thresholds.perFile, }) + + if (this.options.thresholds.autoUpdate && allTestsRun) { + this.updateThresholds({ + thresholds: resolvedThresholds, + perFile: this.options.thresholds.perFile, + configurationFile: this.ctx.server.config.configFile, + }) + } } } diff --git a/packages/coverage-v8/src/provider.ts b/packages/coverage-v8/src/provider.ts index 0e8ee984356a8..0a8dcd096b585 100644 --- a/packages/coverage-v8/src/provider.ts +++ b/packages/coverage-v8/src/provider.ts @@ -72,10 +72,6 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage provider: 'v8', reporter: this.resolveReporters(config.reporter || coverageConfigDefaults.reporter), reportsDirectory: resolve(ctx.config.root, config.reportsDirectory || coverageConfigDefaults.reportsDirectory), - lines: config['100'] ? 100 : config.lines, - functions: config['100'] ? 100 : config.functions, - branches: config['100'] ? 100 : config.branches, - statements: config['100'] ? 100 : config.statements, } this.testExclude = new _TestExclude({ @@ -156,34 +152,25 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage }).execute(context) } - if (this.options.branches - || this.options.functions - || this.options.lines - || this.options.statements) { - this.checkThresholds({ + if (this.options.thresholds) { + const resolvedThresholds = this.resolveThresholds({ coverageMap, - thresholds: { - branches: this.options.branches, - functions: this.options.functions, - lines: this.options.lines, - statements: this.options.statements, - }, - perFile: this.options.perFile, + thresholds: this.options.thresholds, + createCoverageMap: () => libCoverage.createCoverageMap({}), }) - } - if (this.options.thresholdAutoUpdate && allTestsRun) { - this.updateThresholds({ - coverageMap, - thresholds: { - branches: this.options.branches, - functions: this.options.functions, - lines: this.options.lines, - statements: this.options.statements, - }, - perFile: this.options.perFile, - configurationFile: this.ctx.server.config.configFile, + this.checkThresholds({ + thresholds: resolvedThresholds, + perFile: this.options.thresholds.perFile, }) + + if (this.options.thresholds.autoUpdate && allTestsRun) { + this.updateThresholds({ + thresholds: resolvedThresholds, + perFile: this.options.thresholds.perFile, + configurationFile: this.ctx.server.config.configFile, + }) + } } } diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 301f0f963d536..914ce7f7477e4 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -146,6 +146,7 @@ "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", + "magicast": "^0.3.2", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.4.3", diff --git a/packages/vitest/src/types/coverage.ts b/packages/vitest/src/types/coverage.ts index 6f0be7da13f17..bcd96182ef9ba 100644 --- a/packages/vitest/src/types/coverage.ts +++ b/packages/vitest/src/types/coverage.ts @@ -157,40 +157,27 @@ export interface BaseCoverageOptions { skipFull?: boolean /** - * Check thresholds per file. - * See `lines`, `functions`, `branches` and `statements` for the actual thresholds. + * Configurations for thresholds * - * @default false - */ - perFile?: boolean - - /** - * Threshold for lines - * - * @default undefined - */ - lines?: number - - /** - * Threshold for functions - * - * @default undefined - */ - functions?: number - - /** - * Threshold for branches + * @example * - * @default undefined - */ - branches?: number - - /** - * Threshold for statements + * ```ts + * { + * // Thresholds for all files + * functions: 95, + * branches: 70, + * perFile: true, + * autoUpdate: true, * - * @default undefined + * // Thresholds for utilities + * 'src/utils/**.ts': { + * lines: 100, + * statements: 95, + * } + * } + * ``` */ - statements?: number + thresholds?: Thresholds | ({ [glob: string]: Pick } & Thresholds) /** * Watermarks for statements, lines, branches and functions. @@ -204,13 +191,6 @@ export interface BaseCoverageOptions { lines?: [number, number] } - /** - * Update threshold values automatically when current coverage is higher than earlier thresholds - * - * @default false - */ - thresholdAutoUpdate?: boolean - /** * Generate coverage report even when tests fail. * @@ -224,13 +204,6 @@ export interface BaseCoverageOptions { * @default false */ allowExternal?: boolean - - /** - * Shortcut for `{ lines: 100, functions: 100, branches: 100, statements: 100 }` - * - * @default false - */ - 100?: boolean } export interface CoverageIstanbulOptions extends BaseCoverageOptions { @@ -248,3 +221,30 @@ export interface CustomProviderOptions extends Pick> +} + const THRESHOLD_KEYS: Readonly = ['lines', 'functions', 'statements', 'branches'] +const GLOBAL_THRESHOLDS_KEY = 'global' export class BaseCoverageProvider { /** * Check if current coverage is above configured thresholds and bump the thresholds if needed */ - updateThresholds({ configurationFile, coverageMap, thresholds, perFile }: { - coverageMap: CoverageMap - thresholds: Record - perFile?: boolean - configurationFile?: string - }) { + updateThresholds({ thresholds: allThresholds, configurationFile, perFile }: { thresholds: ResolvedThreshold[]; configurationFile?: string; perFile?: boolean }) { // Thresholds cannot be updated if there is no configuration file and // feature was enabled by CLI, e.g. --coverage.thresholdAutoUpdate if (!configurationFile) throw new Error('Missing configurationFile. The "coverage.thresholdAutoUpdate" can only be enabled when configuration file is used.') - const summaries = perFile - ? coverageMap.files() - .map((file: string) => coverageMap.fileCoverageFor(file).toSummary()) - : [coverageMap.getCoverageSummary()] + let updatedThresholds = false - const thresholdsToUpdate: [Threshold, number][] = [] + // TODO: Handle errors if config file is unexpected + const mod = parseModule(readFileSync(configurationFile, 'utf8')) + const config = mod.exports.default.$type === 'function-call' + ? mod.exports.default.$args[0] + : mod.exports.default - for (const key of THRESHOLD_KEYS) { - const threshold = thresholds[key] ?? 100 - const actual = Math.min(...summaries.map(summary => summary[key].pct)) + for (const { coverageMap, thresholds, name } of allThresholds) { + const summaries = perFile + ? coverageMap.files() + .map((file: string) => coverageMap.fileCoverageFor(file).toSummary()) + : [coverageMap.getCoverageSummary()] - if (actual > threshold) - thresholdsToUpdate.push([key, actual]) - } + const thresholdsToUpdate: [Threshold, number][] = [] + + 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) - return + if (thresholdsToUpdate.length === 0) + continue - const originalConfig = readFileSync(configurationFile, 'utf8') - let updatedConfig = originalConfig + updatedThresholds = true - for (const [threshold, newValue] of thresholdsToUpdate) { - // Find the exact match from the configuration file and replace the value - const previousThreshold = (thresholds[threshold] ?? 100).toString() - const pattern = new RegExp(`(${threshold}\\s*:\\s*)${previousThreshold.replace('.', '\\.')}`) - const matches = originalConfig.match(pattern) + for (const [threshold, newValue] of thresholdsToUpdate) { + if (name === GLOBAL_THRESHOLDS_KEY) + config.test.coverage.thresholds[threshold] = newValue - if (matches) - updatedConfig = updatedConfig.replace(matches[0], matches[1] + newValue) - else - console.error(`Unable to update coverage threshold ${threshold}. No threshold found using pattern ${pattern}`) + else + config.test.coverage.thresholds[name][threshold] = newValue + } } - if (updatedConfig !== originalConfig) { + if (updatedThresholds) { // eslint-disable-next-line no-console console.log('Updating thresholds to configuration file. You may want to push with updated coverage thresholds.') - writeFileSync(configurationFile, updatedConfig, 'utf-8') + writeFileSync(configurationFile, mod.generate().code, 'utf-8') } } /** * Checked collected coverage against configured thresholds. Sets exit code to 1 when thresholds not reached. */ - checkThresholds({ coverageMap, thresholds, perFile }: { - coverageMap: CoverageMap - thresholds: Record - perFile?: boolean - }) { - // Construct list of coverage summaries where thresholds are compared against - const summaries = perFile - ? coverageMap.files() - .map((file: string) => ({ - file, - summary: coverageMap.fileCoverageFor(file).toSummary(), - })) - : [{ - file: null, - summary: coverageMap.getCoverageSummary(), - }] - - // Check thresholds of each summary - for (const { summary, file } of summaries) { - for (const thresholdKey of ['lines', 'functions', 'statements', 'branches'] as const) { - const threshold = thresholds[thresholdKey] - - if (threshold !== undefined) { - const coverage = summary.data[thresholdKey].pct - - if (coverage < threshold) { - process.exitCode = 1 - - /* - * Generate error message based on perFile flag: - * - ERROR: Coverage for statements (33.33%) does not meet threshold (85%) for src/math.ts - * - ERROR: Coverage for statements (50%) does not meet global threshold (85%) - */ - let errorMessage = `ERROR: Coverage for ${thresholdKey} (${coverage}%) does not meet` - - if (!perFile) - errorMessage += ' global' - - errorMessage += ` threshold (${threshold}%)` - - if (perFile && file) - errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}` - - console.error(errorMessage) + checkThresholds({ thresholds: allThresholds, perFile }: { thresholds: ResolvedThreshold[]; perFile?: boolean }) { + for (const { coverageMap, thresholds, name } of allThresholds) { + if (thresholds.branches === undefined + && thresholds.functions === undefined + && thresholds.lines === undefined + && thresholds.statements === undefined) + continue + + // Construct list of coverage summaries where thresholds are compared against + const summaries = perFile + ? coverageMap.files() + .map((file: string) => ({ + file, + summary: coverageMap.fileCoverageFor(file).toSummary(), + })) + : [{ + file: null, + summary: coverageMap.getCoverageSummary(), + }] + + // Check thresholds of each summary + for (const { summary, file } of summaries) { + for (const thresholdKey of ['lines', 'functions', 'statements', 'branches'] as const) { + const threshold = thresholds[thresholdKey] + + if (threshold !== undefined) { + const coverage = summary.data[thresholdKey].pct + + if (coverage < threshold) { + process.exitCode = 1 + + /* + * Generate error message based on perFile flag: + * - ERROR: Coverage for statements (33.33%) does not meet threshold (85%) for src/math.ts + * - ERROR: Coverage for statements (50%) does not meet global threshold (85%) + */ + let errorMessage = `ERROR: Coverage for ${thresholdKey} (${coverage}%) does not meet ${name === GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`} threshold (${threshold}%)` + + if (perFile && file) + errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}` + + console.error(errorMessage) + } } } } } } + /** + * TODO describe this + */ + resolveThresholds({ coverageMap, thresholds, createCoverageMap }: { + coverageMap: CoverageMap + thresholds: NonNullable + createCoverageMap: () => CoverageMap + }): ResolvedThreshold[] { + const resolvedThresholds: ResolvedThreshold[] = [] + const files = coverageMap.files() + const filesMatchedByGlobs: string[] = [] + const globalCoverageMap = createCoverageMap() + + for (const key of Object.keys(thresholds) as (`${keyof typeof thresholds}`[])) { + if (key === 'perFile' || key === 'autoUpdate' || key === '100' || THRESHOLD_KEYS.includes(key)) + continue + + const glob = key + const globThresholds = resolveGlobThresholds(thresholds[glob]) + const globCoverageMap = createCoverageMap() + + const matchingFiles = files.filter(file => mm.isMatch(file, glob)) + filesMatchedByGlobs.push(...matchingFiles) + + 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.filter(file => !filesMatchedByGlobs.includes(file))) { + const fileCoverage = coverageMap.fileCoverageFor(file) + globalCoverageMap.addFileCoverage(fileCoverage) + } + + resolvedThresholds.unshift({ + name: GLOBAL_THRESHOLDS_KEY, + coverageMap: globalCoverageMap, + thresholds: { + lines: thresholds['100'] ? 100 : thresholds.lines, + branches: thresholds['100'] ? 100 : thresholds.branches, + functions: thresholds['100'] ? 100 : thresholds.functions, + statements: thresholds['100'] ? 100 : thresholds.statements, + }, + }) + + return resolvedThresholds + } + /** * Resolve reporters from various configuration options */ @@ -139,3 +202,18 @@ export class BaseCoverageProvider { return resolvedReporters } } + +/** + * Narrow down `unknown` glob thresholds to resolved ones + */ +function resolveGlobThresholds(thresholds: unknown): ResolvedThreshold['thresholds'] { + if (!thresholds || typeof thresholds !== 'object') + return { } + + return { + lines: 'lines' in thresholds && typeof thresholds.lines === 'number' ? thresholds.lines : undefined, + branches: 'branches' in thresholds && typeof thresholds.branches === 'number' ? thresholds.branches : undefined, + functions: 'functions' in thresholds && typeof thresholds.functions === 'number' ? thresholds.functions : undefined, + statements: 'statements' in thresholds && typeof thresholds.statements === 'number' ? thresholds.statements : undefined, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 978759d0a8b3f..2aff8ad9bc1bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1281,6 +1281,9 @@ importers: magic-string: specifier: ^0.30.5 version: 0.30.5 + magicast: + specifier: ^0.3.2 + version: 0.3.2 pathe: specifier: ^1.1.1 version: 1.1.1 @@ -20056,6 +20059,14 @@ packages: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 + /magicast@0.3.2: + resolution: {integrity: sha512-Fjwkl6a0syt9TFN0JSYpOybxiMCkYNEeOTnOTNRbjphirLakznZXAqrXgj/7GG3D1dvETONNwrBfinvAbpunDg==} + dependencies: + '@babel/parser': 7.23.3 + '@babel/types': 7.23.3 + source-map-js: 1.0.2 + dev: false + /make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'}