From 094016298e9cbda65a02f8f1845b1e3c4577bc82 Mon Sep 17 00:00:00 2001
From: Jonah Kagan <jonah@voting.works>
Date: Tue, 10 Dec 2024 11:15:34 -0800
Subject: [PATCH 1/7] feat(coverage): absolute coverage thresholds using
 negative numbers

Match Jest's API for coverage thresholds, treating positive numbers as percentage thresholds (`lines: X` means X% of lines must be covered) and negative numbers as absolute thresholds (`lines: -X` means no more than X lines may be uncovered).
---
 packages/vitest/src/utils/coverage.ts         | 63 +++++++++++++------
 .../test/threshold-absolute-failure.test.ts   | 36 +++++++++++
 ...s => threshold-percentage-failure.test.ts} |  2 +-
 3 files changed, 82 insertions(+), 19 deletions(-)
 create mode 100644 test/coverage-test/test/threshold-absolute-failure.test.ts
 rename test/coverage-test/test/{threshold-failure.test.ts => threshold-percentage-failure.test.ts} (95%)

diff --git a/packages/vitest/src/utils/coverage.ts b/packages/vitest/src/utils/coverage.ts
index 2d60ef22cc63..05a36dd33594 100644
--- a/packages/vitest/src/utils/coverage.ts
+++ b/packages/vitest/src/utils/coverage.ts
@@ -364,25 +364,52 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
           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 (this.options.thresholds?.perFile && file) {
-                errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}`
+            /**
+             * 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)
+              }
+            }
+            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)
             }
           }
         }
diff --git a/test/coverage-test/test/threshold-absolute-failure.test.ts b/test/coverage-test/test/threshold-absolute-failure.test.ts
new file mode 100644
index 000000000000..848943575b1e
--- /dev/null
+++ b/test/coverage-test/test/threshold-absolute-failure.test.ts
@@ -0,0 +1,36 @@
+import { expect } from 'vitest'
+import { sum } from '../fixtures/src/math'
+import { coverageTest, isV8Provider, normalizeURL, runVitest, test } from '../utils'
+
+test('failing absolute thresholds', async () => {
+  const { exitCode, stderr } = await runVitest({
+    include: [normalizeURL(import.meta.url)],
+    coverage: {
+      all: false,
+      include: ['**/fixtures/src/math.ts'],
+      thresholds: {
+        '**/fixtures/src/math.ts': {
+          branches: -1,
+          functions: -2,
+          lines: -5,
+          statements: -1,
+        },
+      },
+    },
+  }, { throwOnError: false })
+
+  expect(exitCode).toBe(1)
+  if (isV8Provider()) {
+    expect(stderr).toContain(`ERROR: Uncovered lines (6) exceed "**/fixtures/src/math.ts" threshold (5)`)
+    expect(stderr).toContain(`ERROR: Uncovered functions (3) exceed "**/fixtures/src/math.ts" threshold (2)`)
+    expect(stderr).toContain('ERROR: Uncovered statements (6) exceed "**/fixtures/src/math.ts" threshold (1)')
+  }
+  else {
+    expect(stderr).toContain(`ERROR: Uncovered functions (3) exceed "**/fixtures/src/math.ts" threshold (2)`)
+    expect(stderr).toContain('ERROR: Uncovered statements (3) exceed "**/fixtures/src/math.ts" threshold (1)')
+  }
+})
+
+coverageTest('cover some lines, but not too much', () => {
+  expect(sum(1, 2)).toBe(3)
+})
diff --git a/test/coverage-test/test/threshold-failure.test.ts b/test/coverage-test/test/threshold-percentage-failure.test.ts
similarity index 95%
rename from test/coverage-test/test/threshold-failure.test.ts
rename to test/coverage-test/test/threshold-percentage-failure.test.ts
index 6b461b1a24d3..4e11c9d94d94 100644
--- a/test/coverage-test/test/threshold-failure.test.ts
+++ b/test/coverage-test/test/threshold-percentage-failure.test.ts
@@ -2,7 +2,7 @@ import { expect } from 'vitest'
 import { sum } from '../fixtures/src/math'
 import { coverageTest, isV8Provider, normalizeURL, runVitest, test } from '../utils'
 
-test('failing thresholds', async () => {
+test('failing percentage thresholds', async () => {
   const { exitCode, stderr } = await runVitest({
     include: [normalizeURL(import.meta.url)],
     coverage: {

From 13e56d3e61e71d7c66ed6e72b7ea0b7c6bb3bc0c Mon Sep 17 00:00:00 2001
From: Jonah Kagan <jonah@voting.works>
Date: Tue, 10 Dec 2024 12:00:05 -0800
Subject: [PATCH 2/7] feat(coverage): Auto-update absolute thresholds

When using absolute coverage thresholds (configured using negative numbers) with the `autoUpdate` flag, set the new thresholds based on the reported number of uncovered entities. If there are no uncovered entities, set the threshold to 100%.
---
 packages/vitest/src/utils/coverage.ts         | 28 +++++++++++++++----
 .../vitest.config.thresholds-auto-update.ts   |  8 +++---
 .../test/threshold-auto-update.test.ts        | 20 ++++++-------
 3 files changed, 37 insertions(+), 19 deletions(-)

diff --git a/packages/vitest/src/utils/coverage.ts b/packages/vitest/src/utils/coverage.ts
index 05a36dd33594..2110c9794f02 100644
--- a/packages/vitest/src/utils/coverage.ts
+++ b/packages/vitest/src/utils/coverage.ts
@@ -443,12 +443,30 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
 
       for (const key of THRESHOLD_KEYS) {
         const threshold = thresholds[key] ?? 100
-        const actual = Math.min(
-          ...summaries.map(summary => summary[key].pct),
-        )
+        /**
+         * 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 actual = Math.min(
+            ...summaries.map(summary => summary[key].pct),
+          )
+
+          if (actual > threshold) {
+            thresholdsToUpdate.push([key, actual])
+          }
+        }
+        else {
+          const absoluteThreshold = threshold * -1
+          const actual = Math.max(
+            ...summaries.map(summary => summary[key].total - summary[key].covered),
+          )
 
-        if (actual > threshold) {
-          thresholdsToUpdate.push([key, actual])
+          if (actual < absoluteThreshold) {
+            // If everything was covered, set new threshold to 100% (since a threshold of 0 would be considered as 0%)
+            const updatedThreshold = actual === 0 ? 100 : actual * -1
+            thresholdsToUpdate.push([key, updatedThreshold])
+          }
         }
       }
 
diff --git a/test/coverage-test/fixtures/configs/vitest.config.thresholds-auto-update.ts b/test/coverage-test/fixtures/configs/vitest.config.thresholds-auto-update.ts
index ca57c233a41e..57dd4a621946 100644
--- a/test/coverage-test/fixtures/configs/vitest.config.thresholds-auto-update.ts
+++ b/test/coverage-test/fixtures/configs/vitest.config.thresholds-auto-update.ts
@@ -9,14 +9,14 @@ export default defineConfig({
         // Global ones
         lines: 0.1,
         functions: 0.2,
-        branches: 0.3,
-        statements: 0.4,
+        branches: -1000,
+        statements: -2000,
 
         '**/src/math.ts': {
           branches: 0.1,
           functions: 0.2,
-          lines: 0.3,
-          statements: 0.4
+          lines: -1000,
+          statements: -2000,
         }
       }
     }
diff --git a/test/coverage-test/test/threshold-auto-update.test.ts b/test/coverage-test/test/threshold-auto-update.test.ts
index f39ea10158b9..529b0c64087a 100644
--- a/test/coverage-test/test/threshold-auto-update.test.ts
+++ b/test/coverage-test/test/threshold-auto-update.test.ts
@@ -20,14 +20,14 @@ test('thresholds.autoUpdate updates thresholds', async () => {
             // Global ones
             lines: 0.1,
             functions: 0.2,
-            branches: 0.3,
-            statements: 0.4,
+            branches: -1000,
+            statements: -2000,
 
             '**/src/math.ts': {
               branches: 0.1,
               functions: 0.2,
-              lines: 0.3,
-              statements: 0.4
+              lines: -1000,
+              statements: -2000,
             }
           }
         }
@@ -56,13 +56,13 @@ test('thresholds.autoUpdate updates thresholds', async () => {
               lines: 55.55,
               functions: 33.33,
               branches: 100,
-              statements: 55.55,
+              statements: -8,
 
               '**/src/math.ts': {
                 branches: 100,
                 functions: 25,
-                lines: 50,
-                statements: 50
+                lines: -6,
+                statements: -6,
               }
             }
           }
@@ -84,13 +84,13 @@ test('thresholds.autoUpdate updates thresholds', async () => {
               lines: 33.33,
               functions: 33.33,
               branches: 100,
-              statements: 33.33,
+              statements: -4,
 
               '**/src/math.ts': {
                 branches: 100,
                 functions: 25,
-                lines: 25,
-                statements: 25
+                lines: -3,
+                statements: -3,
               }
             }
           }

From be080835398982b7f16d3ca3975c396009a18252 Mon Sep 17 00:00:00 2001
From: Jonah Kagan <jonah@voting.works>
Date: Tue, 10 Dec 2024 12:26:45 -0800
Subject: [PATCH 3/7] docs(coverage): Explain positive vs negative threshold
 configuration

Remove links to Istanbul docs since those docs only talk about
percentage thresholds (and they don't contain any additional information
that isn't already in the vitest docs).
---
 docs/config/index.md | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/docs/config/index.md b/docs/config/index.md
index c4d6ed1ca9b5..93e496f430f8 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -1481,7 +1481,11 @@ Do not show files with 100% statement, branch, and function coverage.
 
 #### coverage.thresholds
 
-Options for coverage thresholds
+Options for coverage thresholds.
+
+If a threshold is set to a positive number, it will be interpreted as the minimum percentage of coverage required. For example, setting the lines threshold to `90` means that 90% of lines must be covered.
+
+If a threshold is set to a negative number, it will be treated as the maximum number of uncovered items allowed. For example, setting the lines threshold to `-10` means that no more than 10 lines may be uncovered.
 
 ##### coverage.thresholds.lines
 
@@ -1490,7 +1494,6 @@ Options for coverage thresholds
 - **CLI:** `--coverage.thresholds.lines=<number>`
 
 Global threshold for lines.
-See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-thresholds) for more information.
 
 ##### coverage.thresholds.functions
 
@@ -1499,7 +1502,6 @@ See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-threshol
 - **CLI:** `--coverage.thresholds.functions=<number>`
 
 Global threshold for functions.
-See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-thresholds) for more information.
 
 ##### coverage.thresholds.branches
 
@@ -1508,7 +1510,6 @@ See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-threshol
 - **CLI:** `--coverage.thresholds.branches=<number>`
 
 Global threshold for branches.
-See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-thresholds) for more information.
 
 ##### coverage.thresholds.statements
 
@@ -1517,7 +1518,6 @@ See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-threshol
 - **CLI:** `--coverage.thresholds.statements=<number>`
 
 Global threshold for statements.
-See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-thresholds) for more information.
 
 ##### coverage.thresholds.perFile
 
@@ -1535,7 +1535,7 @@ Check thresholds per file.
 - **Available for providers:** `'v8' | 'istanbul'`
 - **CLI:** `--coverage.thresholds.autoUpdate=<boolean>`
 
-Update all threshold values `lines`, `functions`, `branches` and `statements` to configuration file when current coverage is above the configured thresholds.
+Update all threshold values `lines`, `functions`, `branches` and `statements` to configuration file when current coverage is better than the configured thresholds.
 This option helps to maintain thresholds when coverage is improved.
 
 ##### coverage.thresholds.100

From e9fc7eb8f165c332c5aa75f4a85855647f1a76f2 Mon Sep 17 00:00:00 2001
From: Jonah Kagan <jonahkagan@gmail.com>
Date: Mon, 16 Dec 2024 12:04:35 -0800
Subject: [PATCH 4/7] Update docs/config/index.md
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Ari Perkkiƶ <ari.perkkio@gmail.com>
---
 docs/config/index.md | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/docs/config/index.md b/docs/config/index.md
index 93e496f430f8..d6127a7d294c 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -1487,6 +1487,21 @@ If a threshold is set to a positive number, it will be interpreted as the minimu
 
 If a threshold is set to a negative number, it will be treated as the maximum number of uncovered items allowed. For example, setting the lines threshold to `-10` means that no more than 10 lines may be uncovered.
 
+<!-- eslint-skip -->
+```ts
+{
+  coverage: {
+    thresholds: {
+      // Requires 90% function coverage
+      functions: 90,
+
+      // Require that no more than 10 lines are uncovered
+      lines: -10,
+    }
+  }
+}
+```
+
 ##### coverage.thresholds.lines
 
 - **Type:** `number`

From 9b7614f655aa42353623250ed98178fef893bb54 Mon Sep 17 00:00:00 2001
From: Jonah Kagan <jonah@voting.works>
Date: Mon, 16 Dec 2024 12:08:57 -0800
Subject: [PATCH 5/7] Merge threshold failure tests into one file

---
 ...lure.test.ts => threshold-failure.test.ts} | 26 +++++++++++++++
 .../test/threshold-percentage-failure.test.ts | 33 -------------------
 2 files changed, 26 insertions(+), 33 deletions(-)
 rename test/coverage-test/test/{threshold-absolute-failure.test.ts => threshold-failure.test.ts} (57%)
 delete mode 100644 test/coverage-test/test/threshold-percentage-failure.test.ts

diff --git a/test/coverage-test/test/threshold-absolute-failure.test.ts b/test/coverage-test/test/threshold-failure.test.ts
similarity index 57%
rename from test/coverage-test/test/threshold-absolute-failure.test.ts
rename to test/coverage-test/test/threshold-failure.test.ts
index 848943575b1e..1919b4c08102 100644
--- a/test/coverage-test/test/threshold-absolute-failure.test.ts
+++ b/test/coverage-test/test/threshold-failure.test.ts
@@ -2,6 +2,32 @@ import { expect } from 'vitest'
 import { sum } from '../fixtures/src/math'
 import { coverageTest, isV8Provider, normalizeURL, runVitest, test } from '../utils'
 
+test('failing percentage thresholds', async () => {
+  const { exitCode, stderr } = await runVitest({
+    include: [normalizeURL(import.meta.url)],
+    coverage: {
+      all: false,
+      include: ['**/fixtures/src/math.ts'],
+      thresholds: {
+        '**/fixtures/src/math.ts': {
+          branches: 100,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+      },
+    },
+  }, { throwOnError: false })
+
+  const lines = isV8Provider() ? '50%' : '25%'
+  const statements = isV8Provider() ? '50%' : '25%'
+
+  expect(exitCode).toBe(1)
+  expect(stderr).toContain(`ERROR: Coverage for lines (${lines}) does not meet "**/fixtures/src/math.ts" threshold (100%)`)
+  expect(stderr).toContain(`ERROR: Coverage for statements (${statements}) does not meet "**/fixtures/src/math.ts" threshold (100%)`)
+  expect(stderr).toContain('ERROR: Coverage for functions (25%) does not meet "**/fixtures/src/math.ts" threshold (100%)')
+})
+
 test('failing absolute thresholds', async () => {
   const { exitCode, stderr } = await runVitest({
     include: [normalizeURL(import.meta.url)],
diff --git a/test/coverage-test/test/threshold-percentage-failure.test.ts b/test/coverage-test/test/threshold-percentage-failure.test.ts
deleted file mode 100644
index 4e11c9d94d94..000000000000
--- a/test/coverage-test/test/threshold-percentage-failure.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { expect } from 'vitest'
-import { sum } from '../fixtures/src/math'
-import { coverageTest, isV8Provider, normalizeURL, runVitest, test } from '../utils'
-
-test('failing percentage thresholds', async () => {
-  const { exitCode, stderr } = await runVitest({
-    include: [normalizeURL(import.meta.url)],
-    coverage: {
-      all: false,
-      include: ['**/fixtures/src/math.ts'],
-      thresholds: {
-        '**/fixtures/src/math.ts': {
-          branches: 100,
-          functions: 100,
-          lines: 100,
-          statements: 100,
-        },
-      },
-    },
-  }, { throwOnError: false })
-
-  const lines = isV8Provider() ? '50%' : '25%'
-  const statements = isV8Provider() ? '50%' : '25%'
-
-  expect(exitCode).toBe(1)
-  expect(stderr).toContain(`ERROR: Coverage for lines (${lines}) does not meet "**/fixtures/src/math.ts" threshold (100%)`)
-  expect(stderr).toContain(`ERROR: Coverage for statements (${statements}) does not meet "**/fixtures/src/math.ts" threshold (100%)`)
-  expect(stderr).toContain('ERROR: Coverage for functions (25%) does not meet "**/fixtures/src/math.ts" threshold (100%)')
-})
-
-coverageTest('cover some lines, but not too much', () => {
-  expect(sum(1, 2)).toBe(3)
-})

From bde1341b2d5ed1d48492525e6c7e73a33951d4fd Mon Sep 17 00:00:00 2001
From: Jonah Kagan <jonah@voting.works>
Date: Mon, 16 Dec 2024 12:09:09 -0800
Subject: [PATCH 6/7] Convert threshold undefined check to short circuit loop

---
 packages/vitest/src/utils/coverage.ts | 90 ++++++++++++++-------------
 1 file changed, 46 insertions(+), 44 deletions(-)

diff --git a/packages/vitest/src/utils/coverage.ts b/packages/vitest/src/utils/coverage.ts
index 2110c9794f02..3fba42411c01 100644
--- a/packages/vitest/src/utils/coverage.ts
+++ b/packages/vitest/src/utils/coverage.ts
@@ -363,53 +363,55 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
         for (const thresholdKey of THRESHOLD_KEYS) {
           const threshold = thresholds[thresholdKey]
 
-          if (threshold !== undefined) {
-            /**
-             * 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)
+          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
+
+              /**
+               * 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)
             }
-            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)
+          }
+          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)
             }
           }
         }

From 8bfe5e2cc3df965b2aea660c9cf53fd7b2ab358d Mon Sep 17 00:00:00 2001
From: Jonah Kagan <jonahkagan@gmail.com>
Date: Tue, 17 Dec 2024 09:05:43 -0800
Subject: [PATCH 7/7] Update test/coverage-test/test/threshold-failure.test.ts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Ari Perkkiƶ <ari.perkkio@gmail.com>
---
 test/coverage-test/test/threshold-failure.test.ts | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/test/coverage-test/test/threshold-failure.test.ts b/test/coverage-test/test/threshold-failure.test.ts
index 1919b4c08102..0b66ed6201b9 100644
--- a/test/coverage-test/test/threshold-failure.test.ts
+++ b/test/coverage-test/test/threshold-failure.test.ts
@@ -46,13 +46,14 @@ test('failing absolute thresholds', async () => {
   }, { throwOnError: false })
 
   expect(exitCode).toBe(1)
+
   if (isV8Provider()) {
-    expect(stderr).toContain(`ERROR: Uncovered lines (6) exceed "**/fixtures/src/math.ts" threshold (5)`)
-    expect(stderr).toContain(`ERROR: Uncovered functions (3) exceed "**/fixtures/src/math.ts" threshold (2)`)
+    expect(stderr).toContain('ERROR: Uncovered lines (6) exceed "**/fixtures/src/math.ts" threshold (5)')
+    expect(stderr).toContain('ERROR: Uncovered functions (3) exceed "**/fixtures/src/math.ts" threshold (2)')
     expect(stderr).toContain('ERROR: Uncovered statements (6) exceed "**/fixtures/src/math.ts" threshold (1)')
   }
   else {
-    expect(stderr).toContain(`ERROR: Uncovered functions (3) exceed "**/fixtures/src/math.ts" threshold (2)`)
+    expect(stderr).toContain('ERROR: Uncovered functions (3) exceed "**/fixtures/src/math.ts" threshold (2)')
     expect(stderr).toContain('ERROR: Uncovered statements (3) exceed "**/fixtures/src/math.ts" threshold (1)')
   }
 })