@rpcbase/test 0.177.0 → 0.179.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,37 @@ export default {
27
27
 
28
28
  Only `RB_DISABLE_COVERAGE=1` skips the hooks; every other option lives inside this config file.
29
29
 
30
+ ### Per-folder thresholds
31
+
32
+ Need stricter coverage in a sub-tree? Extend the same `thresholds` object with glob keys (mirroring Jest's `coverageThreshold` syntax):
33
+
34
+ ```ts
35
+ export default {
36
+ workspaceRoot,
37
+ thresholds: {
38
+ global: {
39
+ statements: 90,
40
+ lines: 85,
41
+ functions: 85,
42
+ branches: 70,
43
+ },
44
+ "lib/core/**": {
45
+ statements: 98,
46
+ lines: 95,
47
+ },
48
+ "lib/components/**": {
49
+ functions: 92,
50
+ },
51
+ },
52
+ }
53
+ ```
54
+
55
+ - When `thresholds` only has metric keys, it behaves exactly like before.
56
+ - Adding `thresholds.global` lets you keep a default floor while overriding specific directories.
57
+ - Globs run against POSIX-style paths relative to `workspaceRoot`, but absolute paths work too if you prefer.
58
+ - Metrics you omit inside an override inherit from the global thresholds (or the 75/75/75/60 defaults).
59
+ - If a glob matches no files you'll get a warning and the override is skipped, so typos are easy to spot.
60
+
30
61
  ## 2. Import `test` / `expect` directly
31
62
 
32
63
  ```ts
package/index.d.ts CHANGED
@@ -7,6 +7,19 @@ interface CoverageThresholds {
7
7
  statements: number
8
8
  }
9
9
 
10
+ type CoverageThresholdMap = {
11
+ global?: Partial<CoverageThresholds>
12
+ [pattern: string]: Partial<CoverageThresholds> | number | undefined
13
+ }
14
+
15
+ type CoverageThresholdOption = Partial<CoverageThresholds> | CoverageThresholdMap
16
+
17
+ interface CoverageThresholdTarget {
18
+ id: string
19
+ pattern: string
20
+ thresholds: CoverageThresholds
21
+ }
22
+
10
23
  interface CoverageHarnessOptions {
11
24
  workspaceRoot: string
12
25
  libDirs?: string[]
@@ -14,7 +27,7 @@ interface CoverageHarnessOptions {
14
27
  coverageReportSubdir?: string
15
28
  coverageFileName?: string
16
29
  envPrefix?: string
17
- thresholds?: Partial<CoverageThresholds>
30
+ thresholds?: CoverageThresholdOption
18
31
  disabledEnvVar?: string
19
32
  }
20
33
 
@@ -24,6 +37,7 @@ interface CoverageConfig extends CoverageHarnessOptions {
24
37
  coverageReportDir: string
25
38
  coverageFileName: string
26
39
  thresholds: CoverageThresholds
40
+ thresholdTargets: CoverageThresholdTarget[]
27
41
  coverageEnabled: boolean
28
42
  disabledEnvVar: string
29
43
  }
@@ -44,4 +58,6 @@ export type {
44
58
  CoverageHarnessOptions,
45
59
  CoverageConfig,
46
60
  CoverageThresholds,
61
+ CoverageThresholdOption,
62
+ CoverageThresholdTarget,
47
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpcbase/test",
3
- "version": "0.177.0",
3
+ "version": "0.179.0",
4
4
  "type": "module",
5
5
  "types": "./index.d.ts",
6
6
  "exports": {
@@ -51,6 +51,7 @@
51
51
  "istanbul-reports": "3.1.6",
52
52
  "lodash": "4.17.21",
53
53
  "mongoose": "8.18.0",
54
+ "picomatch": "2.3.1",
54
55
  "v8-to-istanbul": "9.2.0"
55
56
  },
56
57
  "peerDependencies": {},
@@ -8,6 +8,8 @@ const DEFAULT_THRESHOLDS = {
8
8
  statements: 75,
9
9
  }
10
10
 
11
+ const THRESHOLD_KEYS = Object.keys(DEFAULT_THRESHOLDS)
12
+
11
13
  function resolveDir(root, target, fallback) {
12
14
  if (!target) {
13
15
  return path.resolve(root, fallback)
@@ -42,10 +44,7 @@ export function createCoverageConfig(options = {}) {
42
44
  const disabledEnvVar = options.disabledEnvVar ?? "RB_DISABLE_COVERAGE"
43
45
  const coverageEnabled = process.env[disabledEnvVar] !== "1"
44
46
 
45
- const thresholds = {
46
- ...DEFAULT_THRESHOLDS,
47
- ...(options.thresholds ?? {}),
48
- }
47
+ const { global: thresholds, targets: thresholdTargets } = normalizeThresholdOptions(options.thresholds)
49
48
 
50
49
  return {
51
50
  workspaceRoot: resolvedWorkspaceRoot,
@@ -54,7 +53,78 @@ export function createCoverageConfig(options = {}) {
54
53
  coverageReportDir,
55
54
  coverageFileName,
56
55
  thresholds,
56
+ thresholdTargets,
57
57
  coverageEnabled,
58
58
  disabledEnvVar,
59
59
  }
60
60
  }
61
+
62
+ function normalizeThresholdOptions(rawThresholds) {
63
+ const globalThresholds = { ...DEFAULT_THRESHOLDS }
64
+ const targets = []
65
+
66
+ if (!isPlainObject(rawThresholds)) {
67
+ return { global: globalThresholds, targets }
68
+ }
69
+
70
+ for (const key of THRESHOLD_KEYS) {
71
+ const value = rawThresholds[key]
72
+ if (isThresholdValue(value)) {
73
+ globalThresholds[key] = value
74
+ }
75
+ }
76
+
77
+ if (Object.prototype.hasOwnProperty.call(rawThresholds, "global")) {
78
+ if (!isPlainObject(rawThresholds.global)) {
79
+ throw new Error("coverage thresholds: the `global` override must be an object of metric values")
80
+ }
81
+ Object.assign(globalThresholds, pickThresholdOverrides(rawThresholds.global))
82
+ }
83
+
84
+ for (const [pattern, overrides] of Object.entries(rawThresholds)) {
85
+ if (pattern === "global" || THRESHOLD_KEYS.includes(pattern)) {
86
+ continue
87
+ }
88
+
89
+ if (!isPlainObject(overrides)) {
90
+ throw new Error(
91
+ `coverage thresholds: override for "${pattern}" must be an object containing coverage metrics`,
92
+ )
93
+ }
94
+
95
+ targets.push({
96
+ id: pattern,
97
+ pattern,
98
+ thresholds: {
99
+ ...globalThresholds,
100
+ ...pickThresholdOverrides(overrides),
101
+ },
102
+ })
103
+ }
104
+
105
+ return { global: globalThresholds, targets }
106
+ }
107
+
108
+ function pickThresholdOverrides(source) {
109
+ const overrides = {}
110
+ if (!isPlainObject(source)) {
111
+ return overrides
112
+ }
113
+
114
+ for (const key of THRESHOLD_KEYS) {
115
+ const value = source[key]
116
+ if (isThresholdValue(value)) {
117
+ overrides[key] = value
118
+ }
119
+ }
120
+
121
+ return overrides
122
+ }
123
+
124
+ function isPlainObject(value) {
125
+ return value !== null && typeof value === "object" && !Array.isArray(value)
126
+ }
127
+
128
+ function isThresholdValue(value) {
129
+ return typeof value === "number" && Number.isFinite(value)
130
+ }
@@ -4,6 +4,7 @@ import path from "node:path"
4
4
  import * as libCoverage from "istanbul-lib-coverage"
5
5
  import { createContext } from "istanbul-lib-report"
6
6
  import reports from "istanbul-reports"
7
+ import picomatch from "picomatch"
7
8
  import v8ToIstanbul from "v8-to-istanbul"
8
9
 
9
10
  import { findCoverageFiles } from "./files.js"
@@ -11,6 +12,13 @@ import { findCoverageFiles } from "./files.js"
11
12
 
12
13
  const TEXT_REPORT_FILENAME = "coverage.txt"
13
14
 
15
+ export class CoverageThresholdError extends Error {
16
+ constructor(message) {
17
+ super(message)
18
+ this.name = "CoverageThresholdError"
19
+ }
20
+ }
21
+
14
22
  export async function generateCoverageReport(config) {
15
23
  const coverageFiles = await findCoverageFiles(config)
16
24
 
@@ -19,7 +27,8 @@ export async function generateCoverageReport(config) {
19
27
  return
20
28
  }
21
29
 
22
- const coverageMap = resolveCoverageLib().createCoverageMap({})
30
+ const coverageLib = resolveCoverageLib()
31
+ const coverageMap = coverageLib.createCoverageMap({})
23
32
 
24
33
  for (const file of coverageFiles) {
25
34
  const payload = await readCoverageFile(file)
@@ -52,7 +61,25 @@ export async function generateCoverageReport(config) {
52
61
  console.log(`[coverage] Full text report saved to ${path.join(config.coverageReportDir, TEXT_REPORT_FILENAME)}`)
53
62
 
54
63
  const summary = coverageMap.getCoverageSummary()
55
- enforceThresholds(summary, config.thresholds)
64
+ enforceThresholds(summary, config.thresholds, "global")
65
+
66
+ const targets = Array.isArray(config.thresholdTargets) ? config.thresholdTargets : []
67
+ if (targets.length > 0) {
68
+ const fileSummaries = buildFileSummaries(coverageMap, config.workspaceRoot)
69
+ for (const target of targets) {
70
+ const matcher = createGlobMatcher(target.pattern)
71
+ const matchResult = collectTargetSummary(fileSummaries, matcher, coverageLib)
72
+
73
+ if (matchResult.matched === 0) {
74
+ console.warn(
75
+ `[coverage] threshold pattern "${target.pattern}" did not match any files — skipping`,
76
+ )
77
+ continue
78
+ }
79
+
80
+ enforceThresholds(matchResult.summary, target.thresholds, target.pattern)
81
+ }
82
+ }
56
83
  }
57
84
 
58
85
  async function mergeScriptCoverage(coverageMap, script) {
@@ -81,7 +108,7 @@ async function readCoverageFile(file) {
81
108
  }
82
109
  }
83
110
 
84
- function enforceThresholds(summary, thresholds) {
111
+ function enforceThresholds(summary, thresholds, label = "global") {
85
112
  const failures = []
86
113
 
87
114
  for (const metric of Object.keys(thresholds)) {
@@ -100,7 +127,7 @@ function enforceThresholds(summary, thresholds) {
100
127
  .map(({ metric, actual, minimum }) => `${metric}: ${actual.toFixed(2)}% < ${minimum}%`)
101
128
  .join("; ")
102
129
 
103
- throw new Error(`[coverage] thresholds not met — ${details}`)
130
+ throw new CoverageThresholdError(`[coverage] thresholds not met (target: ${label}) — ${details}`)
104
131
  }
105
132
 
106
133
  function resolveCoverageLib() {
@@ -115,3 +142,51 @@ function resolveCoverageLib() {
115
142
 
116
143
  throw new Error("istanbul-lib-coverage exports are unavailable")
117
144
  }
145
+
146
+ function buildFileSummaries(coverageMap, workspaceRoot) {
147
+ const normalizedRoot = path.resolve(workspaceRoot)
148
+ return coverageMap.files().map((filePath) => {
149
+ const normalizedAbsolute = path.resolve(filePath)
150
+ const summary = coverageMap.fileCoverageFor(filePath).toSummary()
151
+ const relativePath = path.relative(normalizedRoot, normalizedAbsolute)
152
+ const candidates = new Set([toPosix(normalizedAbsolute)])
153
+
154
+ if (relativePath && !relativePath.startsWith("..")) {
155
+ const relativePosix = toPosix(relativePath)
156
+ candidates.add(relativePosix)
157
+ candidates.add(`./${relativePosix}`)
158
+ candidates.add(`/${relativePosix}`)
159
+ }
160
+
161
+ return {
162
+ summary,
163
+ candidates: Array.from(candidates),
164
+ }
165
+ })
166
+ }
167
+
168
+ function collectTargetSummary(fileSummaries, matcher, coverageLib) {
169
+ const summary = coverageLib.createCoverageSummary()
170
+ let matched = 0
171
+
172
+ for (const file of fileSummaries) {
173
+ if (file.candidates.some((candidate) => matcher(candidate))) {
174
+ summary.merge(file.summary)
175
+ matched += 1
176
+ }
177
+ }
178
+
179
+ return { summary, matched }
180
+ }
181
+
182
+ function createGlobMatcher(pattern) {
183
+ const normalized = toPosix(String(pattern ?? "")).trim()
184
+ if (!normalized) {
185
+ return () => false
186
+ }
187
+ return picomatch(normalized, { dot: true })
188
+ }
189
+
190
+ function toPosix(input) {
191
+ return input.split(path.sep).join("/")
192
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises"
2
2
 
3
- import { generateCoverageReport } from "./report.js"
3
+ import { CoverageThresholdError, generateCoverageReport } from "./report.js"
4
4
  import { removeCoverageFiles } from "./files.js"
5
5
 
6
6
 
@@ -21,14 +21,37 @@ class CoverageReporter {
21
21
  await fs.rm(this.config.coverageReportDir, { recursive: true, force: true })
22
22
  }
23
23
 
24
- async onEnd() {
24
+ async onEnd(result) {
25
25
  if (!this.config.coverageEnabled) {
26
26
  return
27
27
  }
28
28
 
29
- await generateCoverageReport(this.config)
29
+ try {
30
+ await generateCoverageReport(this.config)
31
+ } catch (error) {
32
+ if (error instanceof CoverageThresholdError) {
33
+ console.error(error.message)
34
+ setFailureExitCode(result)
35
+ return
36
+ }
37
+
38
+ throw error
39
+ }
30
40
  }
31
41
  }
32
42
 
33
43
  export { CoverageReporter }
34
44
  export default CoverageReporter
45
+
46
+ function setFailureExitCode(result) {
47
+ if (result && typeof result === "object") {
48
+ result.status = "failed"
49
+ if (typeof result.exitCode !== "number" || result.exitCode === 0) {
50
+ result.exitCode = 1
51
+ }
52
+ }
53
+
54
+ if (!process.exitCode || process.exitCode === 0) {
55
+ process.exitCode = 1
56
+ }
57
+ }