From 13b78d98b5ccc3bd0db3fbd8eb0023c1fab6a7c6 Mon Sep 17 00:00:00 2001 From: Vladlen Kaveev <67223203+vladlenskiy@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:34:23 +0500 Subject: [PATCH] feat(coverage)!: allow `thresholds.perFile` to accept an object (#10190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ari Perkkiö --- docs/config/coverage.md | 68 +++++- docs/guide/cli-generated.md | 4 +- docs/guide/migration.md | 21 ++ packages/vitest/src/node/cli/cli-config.ts | 4 +- packages/vitest/src/node/coverage.ts | 198 +++++++++++------ packages/vitest/src/node/types/coverage.ts | 16 +- .../test/configuration-options.test-d.ts | 79 +++++++ .../test/threshold-auto-update.unit.test.ts | 83 +++++++ .../coverage-test/test/threshold-glob.test.ts | 77 +++++++ .../test/threshold-per-file.test.ts | 203 ++++++++++++++++++ 10 files changed, 672 insertions(+), 81 deletions(-) create mode 100644 test/coverage-test/test/threshold-per-file.test.ts diff --git a/docs/config/coverage.md b/docs/config/coverage.md index 12cb00c1f..409d57fd5 100644 --- a/docs/config/coverage.md +++ b/docs/config/coverage.md @@ -233,12 +233,67 @@ Global threshold for statements. ### coverage.thresholds.perFile -- **Type:** `boolean` +- **Type:** `boolean | { 100?: boolean, lines?: number, functions?: number, branches?: number, statements?: number }` - **Default:** `false` - **Available for providers:** `'v8' | 'istanbul'` - **CLI:** `--coverage.thresholds.perFile`, `--coverage.thresholds.perFile=false` -Check thresholds per file. +When `true`, each file is checked against the top-level thresholds instead of the project-wide aggregate. When set to an object, both are checked: the aggregate against the top-level thresholds, and every file against these per-file minimums. + + +```ts +{ + coverage: { + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + perFile: { + lines: 50, + functions: 50, + branches: 50, + statements: 50, + }, + } + } +} +``` + +`{ 100: true }` is also accepted inside the object as a shortcut for setting all four metrics to `100`: + + +```ts +{ + coverage: { + thresholds: { + lines: 80, + perFile: { + 100: true, + }, + } + } +} +``` + +`perFile` can also be set on an individual [glob-pattern threshold](/config/coverage#coverage-thresholds-glob-pattern). Glob patterns do **not** inherit the top-level `perFile`; set it on each glob explicitly. + + +```ts +{ + coverage: { + thresholds: { + perFile: true, + lines: 80, + + 'src/utils/**': { + lines: 90, + perFile: true, + }, + } + } +} +``` ### coverage.thresholds.autoUpdate @@ -257,9 +312,6 @@ You can also pass a function for formatting the updated threshold values. The fu { coverage: { thresholds: { - // Update thresholds without decimals - autoUpdate: (newThreshold) => Math.floor(newThreshold), - // Log the change and update without decimals autoUpdate: (newThreshold, previousThreshold) => { console.log(`Updated threshold from ${previousThreshold} to ${newThreshold}`) @@ -285,12 +337,14 @@ Shortcut for `--coverage.thresholds.lines 100 --coverage.thresholds.functions 10 ### coverage.thresholds[glob-pattern] -- **Type:** `{ statements?: number functions?: number branches?: number lines?: number }` +- **Type:** `{ statements?: number, functions?: number, branches?: number, lines?: number, perFile?: boolean | object }` - **Default:** `undefined` - **Available for providers:** `'v8' | 'istanbul'` Sets thresholds for files matching the glob pattern. +Each glob pattern can set its own `perFile` (`boolean | object`), checked exactly like the top-level `perFile` but scoped to the matched files. Glob patterns do not inherit the top-level `perFile` — set it per glob. + ::: tip NOTE Vitest counts all files, including those covered by glob-patterns, into the global coverage thresholds. This is different from Jest behavior. @@ -311,6 +365,8 @@ This is different from Jest behavior. functions: 90, branches: 85, lines: 80, + // each matching file must individually hit the thresholds above + perFile: true, }, // Files matching this pattern will only have lines thresholds set. diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index 9fe528713..abb399556 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -197,10 +197,10 @@ Shortcut to set all coverage thresholds to 100 (default: `false`) ### coverage.thresholds.perFile -- **CLI:** `--coverage.thresholds.perFile` +- **CLI:** `--coverage.thresholds.perFile ` - **Config:** [coverage.thresholds.perFile](/config/coverage#coverage-thresholds-perfile) -Check thresholds per file. See `--coverage.thresholds.lines`, `--coverage.thresholds.functions`, `--coverage.thresholds.branches` and `--coverage.thresholds.statements` for the actual thresholds (default: `false`) +Check thresholds per file. See `--coverage.thresholds.lines`, `--coverage.thresholds.functions`, `--coverage.thresholds.branches` and `--coverage.thresholds.statements` for the actual thresholds (default: `false`). Object form is available in config files only. ### coverage.thresholds.autoUpdate diff --git a/docs/guide/migration.md b/docs/guide/migration.md index b428acc22..077640c36 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -125,6 +125,27 @@ await expect.element(banner).toMatchTextContent(/error/i) // [!code ++] await expect.element(banner).toHaveTextContent('Error!') ``` +### Glob Coverage Thresholds No Longer Inherit `perFile` + +`coverage.thresholds.perFile` previously applied to every threshold set, including files matched by glob-pattern thresholds. Glob patterns now control their own per-file checking and no longer inherit the top-level `perFile` — set `perFile` on each glob that needs it. + +```ts [vitest.config.ts] +export default defineConfig({ + test: { + coverage: { + thresholds: { + 'perFile': true, + + 'src/utils/**': { + lines: 80, + perFile: true, // [!code ++] + }, + }, + }, + }, +}) +``` + ### Config Files Are Not Looked Up From Parent Directories Vitest no longer searches parent directories for config files. If you previously relied on running `vitest` from a subdirectory while using a config file from a parent directory, pass the config explicitly and scope test discovery with `--dir`. For example, diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 1348bea2a..ed21e3b39 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -215,7 +215,9 @@ export const cliOptionsConfig: VitestCLIOptions = { subcommands: { perFile: { description: - 'Check thresholds per file. See `--coverage.thresholds.lines`, `--coverage.thresholds.functions`, `--coverage.thresholds.branches` and `--coverage.thresholds.statements` for the actual thresholds (default: `false`)', + 'Check thresholds per file. See `--coverage.thresholds.lines`, `--coverage.thresholds.functions`, `--coverage.thresholds.branches` and `--coverage.thresholds.statements` for the actual thresholds (default: `false`). Object form is available in config files only.', + subcommands: null, + argument: '', }, autoUpdate: { description: diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts index 63f5dbb25..accc7bf47 100644 --- a/packages/vitest/src/node/coverage.ts +++ b/packages/vitest/src/node/coverage.ts @@ -1,4 +1,4 @@ -import type { CoverageMap } from 'istanbul-lib-coverage' +import type { CoverageMap, CoverageSummary } from 'istanbul-lib-coverage' import type { TransformResult } from 'vite' import type { Vitest } from '../node/core' import type { CoverageModuleLoader, CoverageOptions, CoverageProvider, ReportContext, ResolvedCoverageOptions } from '../node/types/coverage' @@ -24,6 +24,10 @@ interface ResolvedThreshold { coverageMap: CoverageMap name: string thresholds: Partial> + /** When `true`, check `thresholds` against each file instead of the aggregate. */ + perFile: boolean + /** Additional per-file-only minimums (object form of `perFile`), or `null`. */ + perFileThresholds: Partial> | null } /** @@ -451,7 +455,8 @@ export class BaseCoverageProvider { } const glob = key - const globThresholds = resolveGlobThresholds(this.options.thresholds![glob]) + const globEntry = this.options.thresholds![glob] + const globThresholds = resolveGlobThresholds(globEntry) const globCoverageMap = this.createCoverageMap() const matcher = pm(glob) @@ -468,6 +473,7 @@ export class BaseCoverageProvider { name: glob, coverageMap: globCoverageMap, thresholds: globThresholds, + ...resolvePerFile(globEntry), }) } @@ -486,6 +492,7 @@ export class BaseCoverageProvider { lines: this.options.thresholds?.lines, statements: this.options.thresholds?.statements, }, + ...resolvePerFile(this.options.thresholds), }) return resolvedThresholds @@ -495,80 +502,106 @@ export class BaseCoverageProvider { * Check collected coverage against configured thresholds. Sets exit code to 1 when thresholds not reached. */ private checkThresholds(allThresholds: ResolvedThreshold[]) { - for (const { coverageMap, thresholds, name } of allThresholds) { - if ( - thresholds.branches === undefined - && thresholds.functions === undefined - && thresholds.lines === undefined - && thresholds.statements === undefined - ) { - continue + for (const { coverageMap, thresholds, perFile, perFileThresholds, name } of allThresholds) { + const groups: { + file: string | null + thresholds: ResolvedThreshold['thresholds'] + summary: CoverageSummary + name: string + }[] = [] + + if (!perFile) { + groups.push({ + file: null, + thresholds, + summary: coverageMap.getCoverageSummary(), + name: name === GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`, + }) } - // Construct list of coverage summaries where thresholds are compared against - const summaries = this.options.thresholds?.perFile - ? coverageMap.files().map((file: string) => ({ + if (perFile) { + for (const file of coverageMap.files().sort()) { + groups.push({ file, + thresholds, summary: coverageMap.fileCoverageFor(file).toSummary(), - })) - : [{ file: null, summary: coverageMap.getCoverageSummary() }] + name: name === GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`, + }) + } + } - // Check thresholds of each summary - for (const { summary, file } of summaries) { - for (const thresholdKey of THRESHOLD_KEYS) { - const threshold = thresholds[thresholdKey] + if (perFileThresholds) { + for (const file of coverageMap.files().sort()) { + groups.push({ + file, + thresholds: perFileThresholds, + summary: coverageMap.fileCoverageFor(file).toSummary(), + name: 'per-file', + }) + } + } - if (threshold === undefined) { - continue - } + for (const group of groups) { + if ( + group.thresholds.branches === undefined + && group.thresholds.functions === undefined + && group.thresholds.lines === undefined + && group.thresholds.statements === undefined + ) { + continue + } + + this.reportThresholdViolations(group.thresholds, group.summary, group.file, group.name) + } + } + } + + private reportThresholdViolations( + thresholds: ResolvedThreshold['thresholds'], + summary: CoverageSummary, + file: string | null, + label: string, + ) { + for (const thresholdKey of THRESHOLD_KEYS) { + const threshold = thresholds[thresholdKey] + + if (threshold === undefined) { + continue + } + + /** + * Positive thresholds are treated as minimum coverage percentages (X means: X% of lines must be covered), + * while negative thresholds are treated as maximum uncovered counts (-X means: X lines may be uncovered). + */ + if (threshold >= 0) { + const coverage = summary.data[thresholdKey].pct + + if (coverage < threshold) { + process.exitCode = 1 - /** - * Positive thresholds are treated as minimum coverage percentages (X means: X% of lines must be covered), - * while negative thresholds are treated as maximum uncovered counts (-X means: X lines may be uncovered). - */ - if (threshold >= 0) { - 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 (this.options.thresholds?.perFile && file) { - errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}` - } - - this.ctx.logger.error(errorMessage) - } + let errorMessage = `ERROR: Coverage for ${thresholdKey} (${coverage}%) does not meet ${label} threshold (${threshold}%)` + + if (file) { + errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}` } - else { - const uncovered = summary.data[thresholdKey].total - summary.data[thresholdKey].covered - const absoluteThreshold = threshold * -1 - - if (uncovered > absoluteThreshold) { - process.exitCode = 1 - - /** - * Generate error message based on perFile flag: - * - ERROR: Uncovered statements (33) exceed threshold (30) for src/math.ts - * - ERROR: Uncovered statements (33) exceed global threshold (30) - */ - let errorMessage = `ERROR: Uncovered ${thresholdKey} (${uncovered}) exceed ${name === GLOBAL_THRESHOLDS_KEY ? name : `"${name}"` - } threshold (${absoluteThreshold})` - - if (this.options.thresholds?.perFile && file) { - errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}` - } - - this.ctx.logger.error(errorMessage) - } + + this.ctx.logger.error(errorMessage) + } + } + else { + const uncovered = summary.data[thresholdKey].total - summary.data[thresholdKey].covered + const absoluteThreshold = threshold * -1 + + if (uncovered > absoluteThreshold) { + process.exitCode = 1 + + let errorMessage = `ERROR: Uncovered ${thresholdKey} (${uncovered}) exceed ${label} threshold (${absoluteThreshold})` + + if (file) { + errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}` } + + this.ctx.logger.error(errorMessage) } } } @@ -587,8 +620,8 @@ export class BaseCoverageProvider { const config = resolveConfig(configurationFile) assertConfigurationModule(config) - for (const { coverageMap, thresholds, name } of allThresholds) { - const summaries = this.options.thresholds?.perFile + for (const { coverageMap, thresholds, name, perFile } of allThresholds) { + const summaries = perFile ? coverageMap .files() .map((file: string) => @@ -596,6 +629,12 @@ export class BaseCoverageProvider { ) : [coverageMap.getCoverageSummary()] + // A `perFile` glob may match no files; skip it instead of writing + // Infinity thresholds from `Math.min(...[])`. + if (summaries.length === 0) { + continue + } + const thresholdsToUpdate: [Threshold, number, number][] = [] for (const key of THRESHOLD_KEYS) { @@ -767,6 +806,27 @@ export class BaseCoverageProvider { } } +function resolvePerFile(thresholds: unknown): { + perFile: boolean + perFileThresholds: ResolvedThreshold['thresholds'] | null +} { + if (!thresholds || typeof thresholds !== 'object' || !('perFile' in thresholds)) { + return { perFile: false, perFileThresholds: null } + } + + const { perFile } = thresholds + + if (perFile === true) { + return { perFile: true, perFileThresholds: null } + } + + if (perFile && typeof perFile === 'object') { + return { perFile: false, perFileThresholds: resolveGlobThresholds(perFile) } + } + + return { perFile: false, perFileThresholds: null } +} + /** * Narrow down `unknown` glob thresholds to resolved ones */ diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index 6a53f7a3d..73d43c9e4 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -217,7 +217,7 @@ export interface CoverageOptions { | ({ [glob: string]: Pick< Thresholds, - 100 | 'statements' | 'functions' | 'branches' | 'lines' + 100 | 'statements' | 'functions' | 'branches' | 'lines' | 'perFile' > } & Thresholds) @@ -331,8 +331,18 @@ interface Thresholds { /** Set global thresholds to `100` */ 100?: boolean - /** Check thresholds per file. */ - perFile?: boolean + /** + * Check thresholds per file. When set to an object, the top-level thresholds + * still apply to the aggregate and every file must additionally meet these + * per-file minimums. + * + * Can also be set per glob pattern via `thresholds[''].perFile`. Glob + * patterns do not inherit this top-level `perFile`; set it on each glob + * explicitly. + * + * @default false + */ + perFile?: boolean | Pick /** * Update threshold values automatically when current coverage is higher than earlier thresholds diff --git a/test/coverage-test/test/configuration-options.test-d.ts b/test/coverage-test/test/configuration-options.test-d.ts index 96c0f49bf..4f186f2f1 100644 --- a/test/coverage-test/test/configuration-options.test-d.ts +++ b/test/coverage-test/test/configuration-options.test-d.ts @@ -43,6 +43,7 @@ test('provider options, generic', () => { branches: 12, functions: 12, statements: 12, + perFile: true, }, }, }) @@ -70,6 +71,84 @@ test('provider options, generic', () => { }, }, }) + + assertType({ + provider: 'v8', + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + perFile: { + lines: 50, + functions: 50, + branches: 50, + statements: 50, + }, + }, + }) + + // Glob patterns accept their own `perFile` (boolean or object). + assertType({ + provider: 'v8', + thresholds: { + '**/some-file.ts': { + perFile: true, + }, + '**/other-file.ts': { + perFile: { + lines: 50, + }, + }, + '**/strict.ts': { + perFile: { + 100: true, + }, + }, + }, + }) + + assertType({ + provider: 'v8', + thresholds: { + '**/some-file.ts': { + perFile: { + // @ts-expect-error -- per-file threshold values must be numbers + lines: '50', + }, + }, + }, + }) + + assertType({ + provider: 'istanbul', + thresholds: { + lines: 80, + perFile: { + 100: true, + }, + }, + }) + + assertType({ + provider: 'v8', + thresholds: { + perFile: { + // @ts-expect-error -- per-file threshold values must be numbers + lines: '50', + }, + }, + }) + + assertType({ + provider: 'v8', + thresholds: { + perFile: { + // @ts-expect-error -- `autoUpdate` is not a per-file option + autoUpdate: true, + }, + }, + }) }) test('provider module', () => { diff --git a/test/coverage-test/test/threshold-auto-update.unit.test.ts b/test/coverage-test/test/threshold-auto-update.unit.test.ts index 9d8791a7a..ce2f7fce3 100644 --- a/test/coverage-test/test/threshold-auto-update.unit.test.ts +++ b/test/coverage-test/test/threshold-auto-update.unit.test.ts @@ -156,6 +156,87 @@ test('formats values with custom formatter', async () => { expect(calls.sort()).toEqual([50, 60, 70, 80]) }) +test('per-file autoUpdate uses the lowest file and skips globs with no matched files', async () => { + const config = parseModule(`export default ${JSON.stringify(defineConfig({ + test: { + coverage: { + thresholds: { + '**/src/*.ts': { lines: 1 }, + '**/empty/*.ts': { lines: 1 }, + }, + }, + }, + }), null, 2)}`) + + const summaryData = { total: 0, covered: 0, skipped: 0 } + const summaryFor = (pct: number) => createCoverageSummary({ + lines: { pct, ...summaryData }, + statements: { pct, ...summaryData }, + branches: { pct, ...summaryData }, + functions: { pct, ...summaryData }, + }) + + const thresholds = [ + { + name: '**/src/*.ts', + thresholds: { lines: 1, branches: 1, functions: 1, statements: 1 }, + perFile: true, + perFileThresholds: null, + coverageMap: { + files: () => ['a.ts', 'b.ts'], + fileCoverageFor: (file: string) => ({ + toSummary: () => summaryFor(file === 'a.ts' ? 80 : 30), + }), + } as unknown as CoverageMap, + }, + { + name: '**/empty/*.ts', + thresholds: { lines: 1, branches: 1, functions: 1, statements: 1 }, + perFile: true, + perFileThresholds: null, + coverageMap: { + files: () => [], + } as unknown as CoverageMap, + }, + ] + + const updated = await new Promise((resolve, reject) => { + const provider = new BaseCoverageProvider() + + provider._initialize({ + config: { coverage: {} }, + logger: { log: () => {} }, + _coverageOptions: {}, + } as any) + + provider.updateThresholds({ + thresholds, + configurationFile: config, + onUpdate: () => resolve(config.generate().code), + }).catch(error => reject(error)) + }) + + expect(updated).toMatchInlineSnapshot(` + "export default { + "test": { + "coverage": { + "thresholds": { + "**/src/*.ts": { + "lines": 30, + functions: 30, + statements: 30, + branches: 30 + }, + "**/empty/*.ts": { + "lines": 1 + } + } + } + } + }" + `) +}) + test('passes previous threshold as second argument to custom formatter', async () => { const config = parseModule(`export default ${initialConfig}`) @@ -177,6 +258,8 @@ async function updateThresholds(configurationFile: ReturnType createCoverageSummary({ lines: { pct: coveredThresholds.lines, ...summaryData }, diff --git a/test/coverage-test/test/threshold-glob.test.ts b/test/coverage-test/test/threshold-glob.test.ts index a0871c6cb..4a2f1f888 100644 --- a/test/coverage-test/test/threshold-glob.test.ts +++ b/test/coverage-test/test/threshold-glob.test.ts @@ -57,6 +57,83 @@ test('{ thresholds: { 100: true } } on glob pattern', async () => { `) }) +test('per-file boolean on glob pattern checks each matching file', async () => { + const { stderr, exitCode } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + '**/fixtures/src/*.ts': { + functions: 50, + perFile: true, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Coverage for functions (25%) does not meet "**/fixtures/src/*.ts" threshold (50%) for fixtures/src/math.ts + " + `) +}) + +test('per-file object on glob pattern adds per-file minimums', async () => { + const { stderr, exitCode } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + '**/fixtures/src/*.ts': { + functions: 20, + perFile: { + functions: 50, + }, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Coverage for functions (25%) does not meet per-file threshold (50%) for fixtures/src/math.ts + " + `) +}) + +test('per-file { 100: true } on glob pattern with no aggregate thresholds', async () => { + const { stderr, exitCode } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + '**/fixtures/src/*.ts': { + perFile: { + 100: true, + }, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Coverage for lines (25%) does not meet per-file threshold (100%) for fixtures/src/math.ts + ERROR: Coverage for functions (25%) does not meet per-file threshold (100%) for fixtures/src/math.ts + ERROR: Coverage for statements (25%) does not meet per-file threshold (100%) for fixtures/src/math.ts + " + `) +}) + coverageTest('cover some lines, but not too much', () => { expect(sum(1, 2)).toBe(3) expect(isEven(4)).toBe(true) diff --git a/test/coverage-test/test/threshold-per-file.test.ts b/test/coverage-test/test/threshold-per-file.test.ts new file mode 100644 index 000000000..582cbad50 --- /dev/null +++ b/test/coverage-test/test/threshold-per-file.test.ts @@ -0,0 +1,203 @@ +import { expect } from 'vitest' +import { branch } from '../fixtures/src/branch' +import { isEven, isOdd } from '../fixtures/src/even' +import { sum } from '../fixtures/src/math' +import { coverageTest, normalizeURL, runVitest, test } from '../utils' + +// math.ts: 1/4 functions covered (25%). even.ts: 2/2 (100%). branch.ts: only the +// true branch of the `if` is taken, so branches are 1/2 (50%). + +test('per-file object thresholds fail while global thresholds pass', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + functions: 40, + perFile: { + functions: 50, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Coverage for functions (25%) does not meet per-file threshold (50%) for fixtures/src/math.ts + " + `) +}) + +test('global thresholds fail while per-file object thresholds pass', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + functions: 70, + perFile: { + functions: 20, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Coverage for functions (50%) does not meet global threshold (70%) + " + `) +}) + +test('both global and per-file object thresholds pass', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + functions: 40, + perFile: { + functions: 20, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(0) + expect(stderr).toMatchInlineSnapshot(`""`) +}) + +test('per-file object thresholds with { 100: true }', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/branch.ts', + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + functions: 40, + perFile: { + 100: true, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Coverage for lines (75%) does not meet per-file threshold (100%) for fixtures/src/branch.ts + ERROR: Coverage for statements (75%) does not meet per-file threshold (100%) for fixtures/src/branch.ts + ERROR: Coverage for branches (50%) does not meet per-file threshold (100%) for fixtures/src/branch.ts + ERROR: Coverage for lines (25%) does not meet per-file threshold (100%) for fixtures/src/math.ts + ERROR: Coverage for functions (25%) does not meet per-file threshold (100%) for fixtures/src/math.ts + ERROR: Coverage for statements (25%) does not meet per-file threshold (100%) for fixtures/src/math.ts + " + `) +}) + +test('per-file object thresholds with negative threshold', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + functions: 40, + perFile: { + functions: -1, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Uncovered functions (3) exceed per-file threshold (1) for fixtures/src/math.ts + " + `) +}) + +test('per-file object thresholds with empty object are a no-op', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + functions: 40, + perFile: {}, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(0) + expect(stderr).toMatchInlineSnapshot(`""`) +}) + +test('top-level perFile does not cascade to glob thresholds', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + 'perFile': true, + '**/fixtures/src/*.ts': { + functions: 40, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(0) + expect(stderr).toMatchInlineSnapshot(`""`) +}) + +test('top-level perFile applies globally but not to a glob without its own perFile', async () => { + const { exitCode, stderr } = await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + include: [ + '**/fixtures/src/even.ts', + '**/fixtures/src/math.ts', + ], + thresholds: { + 'functions': 30, + 'perFile': true, + '**/fixtures/src/even.ts': { + functions: 40, + }, + }, + }, + }, { throwOnError: false }) + + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + "ERROR: Coverage for functions (25%) does not meet global threshold (30%) for fixtures/src/math.ts + " + `) +}) + +coverageTest('cover some lines, but not too much', async () => { + expect(sum(1, 2)).toBe(3) + expect(isEven(4)).toBe(true) + expect(isOdd(4)).toBe(false) + expect(await branch(15)).toBe(true) +}) -- 2.51.2