@rpcbase/test 0.305.0 → 0.306.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
@@ -4,17 +4,10 @@
4
4
 
5
5
  ## 1. Declare your coverage config
6
6
 
7
- Create `spec/coverage.ts` (or `.js` / `.json`) in your package:
7
+ Create `spec/coverage.config.ts` (or `.js` / `.json`) in your package:
8
8
 
9
9
  ```ts
10
- import path from "node:path"
11
- import { fileURLToPath } from "node:url"
12
-
13
- const workspaceRoot = path.resolve(fileURLToPath(new URL("../", import.meta.url)))
14
-
15
10
  export default {
16
- workspaceRoot,
17
- libDirs: ["lib"],
18
11
  testResultsDir: "test-results",
19
12
  thresholds: {
20
13
  statements: 75,
@@ -27,13 +20,23 @@ export default {
27
20
 
28
21
  Only `RB_DISABLE_COVERAGE=1` skips the hooks; every other option lives inside this config file.
29
22
 
23
+ ### Default file collection
24
+
25
+ If you omit `collectCoverageFrom`, `rb-test` uses a reasonable default:
26
+
27
+ - `src/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}` when `src/` exists
28
+ - if `src/` is missing, `rb-test` throws unless you set `collectCoverageFrom`
29
+
30
+ Unit test files are excluded by default: `!**/*.test.{ts,tsx,js,jsx,mts,cts,mjs,cjs}`.
31
+
32
+ `collectCoverageFrom` supports negated globs (prefix with `!`) to exclude files.
33
+
30
34
  ### Per-folder thresholds
31
35
 
32
36
  Need stricter coverage in a sub-tree? Extend the same `thresholds` object with glob keys (mirroring Jest's `coverageThreshold` syntax):
33
37
 
34
38
  ```ts
35
39
  export default {
36
- workspaceRoot,
37
40
  thresholds: {
38
41
  global: {
39
42
  statements: 90,
@@ -41,11 +44,11 @@ export default {
41
44
  functions: 85,
42
45
  branches: 70,
43
46
  },
44
- "lib/core/**": {
47
+ "src/core/**": {
45
48
  statements: 98,
46
49
  lines: 95,
47
50
  },
48
- "lib/components/**": {
51
+ "src/components/**": {
49
52
  functions: 92,
50
53
  },
51
54
  },
@@ -54,7 +57,7 @@ export default {
54
57
 
55
58
  - When `thresholds` only has metric keys, it behaves exactly like before.
56
59
  - 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.
60
+ - Globs run against POSIX-style paths relative to the package root (parent folder of `spec/`). Absolute paths are not supported.
58
61
  - Metrics you omit inside an override inherit from the global thresholds (or the 75/75/75/60 defaults).
59
62
  - If a glob matches no files you'll get a warning and the override is skipped, so typos are easy to spot.
60
63
 
@@ -104,7 +107,7 @@ Each package uses its `npm test` script as `tsc --noEmit && rb-test`. The CLI ru
104
107
 
105
108
  Coverage is enforced separately:
106
109
 
107
- - Playwright uses `spec/coverage.*` via the shared reporter and will fail the run if thresholds aren’t met.
110
+ - Playwright uses `spec/coverage.config.*` via the shared reporter and will fail the run if thresholds aren’t met.
108
111
  - Vitest only reads `src/coverage.json` (JSON object). If that file is missing, Vitest coverage is skipped.
109
112
 
110
113
  Need to debug without coverage? Set `RB_DISABLE_COVERAGE=1 npm test` and the Playwright hooks short-circuit.
package/index.d.ts CHANGED
@@ -21,21 +21,22 @@ interface CoverageThresholdTarget {
21
21
  }
22
22
 
23
23
  interface CoverageHarnessOptions {
24
- workspaceRoot: string
25
- libDirs?: string[]
24
+ rootDir: string
25
+ collectCoverageFrom: string[]
26
26
  testResultsDir?: string
27
27
  coverageReportSubdir?: string
28
28
  coverageFileName?: string
29
+ includeAllFiles?: boolean
29
30
  envPrefix?: string
30
31
  thresholds?: CoverageThresholdOption
31
32
  disabledEnvVar?: string
32
33
  }
33
34
 
34
35
  interface CoverageConfig extends CoverageHarnessOptions {
35
- libRoots: string[]
36
36
  testResultsRoot: string
37
37
  coverageReportDir: string
38
38
  coverageFileName: string
39
+ includeAllFiles: boolean
39
40
  thresholds: CoverageThresholds
40
41
  thresholdTargets: CoverageThresholdTarget[]
41
42
  coverageEnabled: boolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpcbase/test",
3
- "version": "0.305.0",
3
+ "version": "0.306.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -56,7 +56,9 @@
56
56
  "dependencies": {
57
57
  "@playwright/test": "1.57.0",
58
58
  "esbuild": "0.27.2",
59
+ "fast-glob": "3.3.3",
59
60
  "istanbul-lib-coverage": "3.2.2",
61
+ "istanbul-lib-instrument": "6.0.3",
60
62
  "istanbul-lib-report": "3.0.1",
61
63
  "istanbul-reports": "3.2.0",
62
64
  "lodash": "4.17.21",
package/src/cli.js CHANGED
@@ -8,6 +8,7 @@ import { createRequire } from "module"
8
8
  import { fileURLToPath } from "url"
9
9
 
10
10
  import { createCoverageConfig } from "./coverage/config.js"
11
+ import { isInsideAnyRoot, resolveCollectCoverageRoots } from "./coverage/collect.js"
11
12
  import { loadCoverageOptions } from "./coverage/config-loader.js"
12
13
  import { removeCoverageFiles } from "./coverage/files.js"
13
14
  import { CoverageThresholdError, generateCoverageReport } from "./coverage/report.js"
@@ -65,7 +66,12 @@ async function runTests() {
65
66
 
66
67
  runTests()
67
68
  .then(() => process.exit(0))
68
- .catch(() => process.exit(1))
69
+ .catch((error) => {
70
+ if (!(error instanceof CoverageThresholdError)) {
71
+ console.error(error?.stack ?? String(error))
72
+ }
73
+ process.exit(1)
74
+ })
69
75
 
70
76
  async function runVitest(coverage, combinedConfig) {
71
77
  const vitestArgs = ["run", "--passWithNoTests"]
@@ -298,6 +304,7 @@ async function convertNodeCoverage(coverage) {
298
304
 
299
305
  const entries = await fsPromises.readdir(nodeCoverageDir).catch(() => [])
300
306
  const scripts = []
307
+ const scriptRoots = resolveCollectCoverageRoots(config.collectCoverageFrom, config.rootDir)
301
308
 
302
309
  for (const entry of entries) {
303
310
  if (!entry.endsWith(".json")) {
@@ -309,12 +316,16 @@ async function convertNodeCoverage(coverage) {
309
316
  const results = Array.isArray(payload?.result) ? payload.result : []
310
317
 
311
318
  for (const script of results) {
312
- const normalized = normalizeNodeScriptUrl(script.url, config.workspaceRoot)
319
+ const normalized = normalizeNodeScriptUrl(script.url, config.rootDir)
313
320
  if (!normalized) {
314
321
  continue
315
322
  }
316
323
 
317
- if (!isInsideLib(normalized.absolutePath, config.libRoots)) {
324
+ if (isNodeModulesPath(normalized.absolutePath)) {
325
+ continue
326
+ }
327
+
328
+ if (!isInsideAnyRoot(normalized.absolutePath, scriptRoots)) {
318
329
  continue
319
330
  }
320
331
 
@@ -360,7 +371,7 @@ async function readJson(filePath) {
360
371
  }
361
372
  }
362
373
 
363
- function normalizeNodeScriptUrl(rawUrl, workspaceRoot) {
374
+ function normalizeNodeScriptUrl(rawUrl, rootDir) {
364
375
  if (!rawUrl || rawUrl.startsWith("node:")) {
365
376
  return null
366
377
  }
@@ -384,21 +395,18 @@ function normalizeNodeScriptUrl(rawUrl, workspaceRoot) {
384
395
  }
385
396
 
386
397
  const normalized = path.normalize(absolutePath)
387
- if (!normalized.startsWith(workspaceRoot)) {
388
- return null
389
- }
390
398
 
391
399
  return {
392
400
  absolutePath: normalized,
393
- relativePath: path.relative(workspaceRoot, normalized),
401
+ relativePath: path.relative(rootDir, normalized),
394
402
  }
395
403
  }
396
404
 
397
- function isInsideLib(absolutePath, libRoots) {
398
- return libRoots.some((libRoot) => {
399
- const relative = path.relative(libRoot, absolutePath)
400
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
401
- })
405
+ function isNodeModulesPath(filePath) {
406
+ return path
407
+ .normalize(String(filePath ?? ""))
408
+ .split(path.sep)
409
+ .includes("node_modules")
402
410
  }
403
411
 
404
412
  function spawnWithLogs({ name, launcher, args, env, successMessage, failureMessage }) {
@@ -0,0 +1,134 @@
1
+ import path from "node:path"
2
+
3
+ import picomatch from "picomatch"
4
+
5
+
6
+ export function createCollectCoverageMatcher(patterns, rootDir) {
7
+ const normalizedRoot = path.resolve(String(rootDir ?? ""))
8
+
9
+ const includeMatchers = []
10
+ const excludeMatchers = []
11
+
12
+ if (Array.isArray(patterns)) {
13
+ for (const pattern of patterns) {
14
+ const raw = String(pattern ?? "").trim()
15
+ if (!raw) {
16
+ continue
17
+ }
18
+
19
+ const isExclude = raw.startsWith("!")
20
+ const body = toPosix(isExclude ? raw.slice(1) : raw)
21
+ if (!body) {
22
+ continue
23
+ }
24
+
25
+ const matcher = picomatch(body, { dot: true })
26
+ if (isExclude) {
27
+ excludeMatchers.push(matcher)
28
+ } else {
29
+ includeMatchers.push(matcher)
30
+ }
31
+ }
32
+ }
33
+
34
+ if (includeMatchers.length === 0) {
35
+ return () => false
36
+ }
37
+
38
+ return (absolutePath) => {
39
+ const normalizedAbsolute = path.resolve(String(absolutePath ?? ""))
40
+ if (!normalizedAbsolute) {
41
+ return false
42
+ }
43
+
44
+ const relativePosix = toPosix(path.relative(normalizedRoot, normalizedAbsolute))
45
+ const absolutePosix = toPosix(normalizedAbsolute)
46
+ const candidates = new Set([absolutePosix, relativePosix])
47
+
48
+ if (relativePosix) {
49
+ candidates.add(`./${relativePosix}`)
50
+ }
51
+
52
+ const candidateList = Array.from(candidates)
53
+ const included = includeMatchers.some((matcher) => candidateList.some((candidate) => matcher(candidate)))
54
+ if (!included) {
55
+ return false
56
+ }
57
+
58
+ return !excludeMatchers.some((matcher) => candidateList.some((candidate) => matcher(candidate)))
59
+ }
60
+ }
61
+
62
+ export function toPosix(input) {
63
+ return String(input ?? "").split(path.sep).join("/")
64
+ }
65
+
66
+ export function isInsideAnyRoot(absolutePath, roots) {
67
+ const normalizedAbsolute = path.resolve(String(absolutePath ?? ""))
68
+ return (Array.isArray(roots) ? roots : []).some((root) => {
69
+ const normalizedRoot = path.resolve(String(root ?? ""))
70
+ const relative = path.relative(normalizedRoot, normalizedAbsolute)
71
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
72
+ })
73
+ }
74
+
75
+ export function resolveCollectCoverageRoots(patterns, rootDir) {
76
+ const resolvedRootDir = path.resolve(String(rootDir ?? ""))
77
+ const roots = new Set([resolvedRootDir])
78
+
79
+ if (!Array.isArray(patterns)) {
80
+ return Array.from(roots)
81
+ }
82
+
83
+ for (const pattern of patterns) {
84
+ const raw = String(pattern ?? "").trim()
85
+ if (!raw) {
86
+ continue
87
+ }
88
+
89
+ const cleaned = raw.startsWith("!") ? raw.slice(1) : raw
90
+ if (!cleaned) {
91
+ continue
92
+ }
93
+
94
+ const prefix = staticGlobPrefix(cleaned)
95
+ if (!prefix) {
96
+ continue
97
+ }
98
+
99
+ roots.add(path.resolve(resolvedRootDir, prefix))
100
+ }
101
+
102
+ return Array.from(roots)
103
+ }
104
+
105
+ function staticGlobPrefix(pattern) {
106
+ const normalized = String(pattern ?? "").trim()
107
+ if (!normalized) {
108
+ return null
109
+ }
110
+
111
+ const firstGlobIndex = findFirstGlobIndex(normalized)
112
+ const prefix = firstGlobIndex === -1 ? normalized : normalized.slice(0, firstGlobIndex)
113
+
114
+ if (!prefix) {
115
+ return null
116
+ }
117
+
118
+ if (prefix.endsWith("/") || prefix.endsWith(path.sep)) {
119
+ return prefix
120
+ }
121
+
122
+ const posix = prefix.split(path.sep).join("/")
123
+ return path.posix.dirname(posix)
124
+ }
125
+
126
+ function findFirstGlobIndex(value) {
127
+ for (let i = 0; i < value.length; i += 1) {
128
+ const char = value[i]
129
+ if (char === "*" || char === "?" || char === "[" || char === "{") {
130
+ return i
131
+ }
132
+ }
133
+ return -1
134
+ }
@@ -8,6 +8,16 @@ import { build } from "esbuild"
8
8
 
9
9
 
10
10
  const DEFAULT_COVERAGE_CANDIDATES = [
11
+ "spec/coverage.config.ts",
12
+ "spec/coverage.config.mts",
13
+ "spec/coverage.config.cts",
14
+ "spec/coverage.config.js",
15
+ "spec/coverage.config.mjs",
16
+ "spec/coverage.config.cjs",
17
+ "spec/coverage.config.json",
18
+ ]
19
+
20
+ const LEGACY_COVERAGE_CANDIDATES = [
11
21
  "spec/coverage.ts",
12
22
  "spec/coverage.mts",
13
23
  "spec/coverage.cts",
@@ -19,6 +29,15 @@ const DEFAULT_COVERAGE_CANDIDATES = [
19
29
 
20
30
  export async function loadCoverageOptions({ optional = false, candidates = DEFAULT_COVERAGE_CANDIDATES, defaultTestResultsDir } = {}) {
21
31
  const projectRoot = process.cwd()
32
+ const legacy = await findCoverageFile(projectRoot, LEGACY_COVERAGE_CANDIDATES)
33
+ if (legacy) {
34
+ const ext = path.extname(legacy)
35
+ const suggested = path.join("spec", `coverage.config${ext}`)
36
+ throw new Error(
37
+ `Legacy coverage config detected (${path.relative(projectRoot, legacy)}). Rename it to ${suggested}.`,
38
+ )
39
+ }
40
+
22
41
  const resolved = await findCoverageFile(projectRoot, candidates)
23
42
 
24
43
  if (!resolved) {
@@ -26,7 +45,7 @@ export async function loadCoverageOptions({ optional = false, candidates = DEFAU
26
45
  return null
27
46
  }
28
47
  throw new Error(
29
- "Coverage config not found. Create one of: spec/coverage.{ts,js,json} with your coverage settings.",
48
+ "Coverage config not found. Create `spec/coverage.config.{ts,js,json}` with your coverage settings.",
30
49
  )
31
50
  }
32
51
 
@@ -35,7 +54,7 @@ export async function loadCoverageOptions({ optional = false, candidates = DEFAU
35
54
  throw new Error(`Coverage config at ${resolved} must export an object.`)
36
55
  }
37
56
 
38
- return normalizeOptions(raw, resolved, defaultTestResultsDir)
57
+ return await normalizeOptions(raw, resolved, defaultTestResultsDir)
39
58
  }
40
59
 
41
60
  async function findCoverageFile(root, candidates) {
@@ -101,24 +120,85 @@ async function loadModule(url) {
101
120
  return imported
102
121
  }
103
122
 
104
- function normalizeOptions(rawOptions, filePath, defaultTestResultsDir) {
123
+ const DEFAULT_COLLECT_COVERAGE_EXTENSIONS = "ts,tsx,js,jsx,mts,cts,mjs,cjs"
124
+ const DEFAULT_COLLECT_COVERAGE_TEST_EXCLUDE = `!**/*.test.{${DEFAULT_COLLECT_COVERAGE_EXTENSIONS}}`
125
+
126
+ async function resolveCollectCoverageFrom(rawPatterns, rootDir) {
127
+ const resolvedRootDir = path.resolve(String(rootDir ?? ""))
128
+
129
+ const normalized = Array.isArray(rawPatterns)
130
+ ? rawPatterns
131
+ .map((pattern) => String(pattern ?? "").trim())
132
+ .filter((pattern) => pattern.length > 0)
133
+ : []
134
+
135
+ const excludes = normalized.filter((pattern) => pattern.startsWith("!"))
136
+ const hasIncludes = normalized.some((pattern) => !pattern.startsWith("!"))
137
+ if (hasIncludes) {
138
+ return withDefaultExcludes(normalized)
139
+ }
140
+
141
+ const defaults = await inferDefaultCollectCoverageFrom(resolvedRootDir)
142
+ if (defaults.length === 0) {
143
+ throw new Error(
144
+ "Coverage config: couldn't infer a default `collectCoverageFrom` (src/ directory missing). Provide `collectCoverageFrom` with at least one positive glob pattern.",
145
+ )
146
+ }
147
+
148
+ return withDefaultExcludes([...defaults, ...excludes])
149
+ }
150
+
151
+ async function inferDefaultCollectCoverageFrom(rootDir) {
152
+ const srcDir = path.join(rootDir, "src")
153
+ if (await isDirectory(srcDir)) {
154
+ return [`src/**/*.{${DEFAULT_COLLECT_COVERAGE_EXTENSIONS}}`]
155
+ }
156
+
157
+ return []
158
+ }
159
+
160
+ function withDefaultExcludes(patterns) {
161
+ if (!Array.isArray(patterns) || patterns.length === 0) {
162
+ return patterns
163
+ }
164
+
165
+ const hasTestExclude = patterns.some((pattern) => {
166
+ const raw = String(pattern ?? "")
167
+ if (!raw.startsWith("!")) {
168
+ return false
169
+ }
170
+ return /\.test(?:\.|\{|$)/.test(raw.slice(1))
171
+ })
172
+ if (hasTestExclude) {
173
+ return patterns
174
+ }
175
+
176
+ return [...patterns, DEFAULT_COLLECT_COVERAGE_TEST_EXCLUDE]
177
+ }
178
+
179
+ async function isDirectory(filePath) {
180
+ try {
181
+ const stat = await fs.stat(filePath)
182
+ return stat.isDirectory()
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+
188
+ async function normalizeOptions(rawOptions, filePath, defaultTestResultsDir) {
105
189
  const options = { ...rawOptions }
106
190
  const configDir = path.dirname(filePath)
191
+ const rootDir = path.resolve(configDir, "..")
107
192
 
108
- const workspaceRoot = path.resolve(
109
- options.workspaceRoot ? path.resolve(configDir, options.workspaceRoot) : process.cwd(),
110
- )
111
-
112
- const libDirs = Array.isArray(options.libDirs) && options.libDirs.length > 0
113
- ? options.libDirs.map((entry) => path.resolve(workspaceRoot, entry))
114
- : [path.resolve(workspaceRoot, "lib")]
193
+ const collectCoverageFrom = await resolveCollectCoverageFrom(options.collectCoverageFrom, rootDir)
115
194
 
116
195
  return {
117
- workspaceRoot,
118
- libDirs,
196
+ rootDir,
197
+ collectCoverageFrom,
119
198
  testResultsDir: options.testResultsDir ?? defaultTestResultsDir ?? "test-results",
120
199
  coverageReportSubdir: options.coverageReportSubdir ?? "coverage",
121
200
  coverageFileName: options.coverageFileName ?? "v8-coverage.json",
201
+ includeAllFiles: options.includeAllFiles,
122
202
  thresholds: options.thresholds ?? {},
123
203
  }
124
204
  }
@@ -23,22 +23,25 @@ function resolveDir(root, target, fallback) {
23
23
  }
24
24
 
25
25
  export function createCoverageConfig(options = {}) {
26
- const { workspaceRoot } = options
27
- if (!workspaceRoot) {
28
- throw new Error("createCoverageConfig requires a workspaceRoot")
26
+ const { rootDir } = options
27
+ if (!rootDir) {
28
+ throw new Error("createCoverageConfig requires a rootDir")
29
29
  }
30
30
 
31
- const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
31
+ const resolvedRootDir = path.resolve(rootDir)
32
+ const includeAllFiles = options.includeAllFiles !== false
32
33
 
33
- const libDirs = Array.isArray(options.libDirs) && options.libDirs.length > 0
34
- ? options.libDirs
35
- : ["lib"]
34
+ const collectCoverageFrom = Array.isArray(options.collectCoverageFrom)
35
+ ? options.collectCoverageFrom
36
+ .map((pattern) => String(pattern ?? "").trim())
37
+ .filter((pattern) => pattern.length > 0)
38
+ : []
36
39
 
37
- const libRoots = libDirs.map((dir) =>
38
- path.isAbsolute(dir) ? path.normalize(dir) : path.resolve(resolvedWorkspaceRoot, dir),
39
- )
40
+ if (collectCoverageFrom.length === 0) {
41
+ throw new Error("createCoverageConfig requires a collectCoverageFrom option")
42
+ }
40
43
 
41
- const testResultsRoot = resolveDir(resolvedWorkspaceRoot, options.testResultsDir, "test-results")
44
+ const testResultsRoot = resolveDir(resolvedRootDir, options.testResultsDir, "test-results")
42
45
  const coverageReportDir = resolveDir(testResultsRoot, options.coverageReportSubdir, "coverage")
43
46
  const coverageFileName = options.coverageFileName ?? "v8-coverage.json"
44
47
  const disabledEnvVar = options.disabledEnvVar ?? "RB_DISABLE_COVERAGE"
@@ -47,8 +50,8 @@ export function createCoverageConfig(options = {}) {
47
50
  const { global: thresholds, targets: thresholdTargets } = normalizeThresholdOptions(options.thresholds)
48
51
 
49
52
  return {
50
- workspaceRoot: resolvedWorkspaceRoot,
51
- libRoots,
53
+ rootDir: resolvedRootDir,
54
+ collectCoverageFrom,
52
55
  testResultsRoot,
53
56
  coverageReportDir,
54
57
  coverageFileName,
@@ -56,6 +59,7 @@ export function createCoverageConfig(options = {}) {
56
59
  thresholdTargets,
57
60
  coverageEnabled,
58
61
  disabledEnvVar,
62
+ includeAllFiles,
59
63
  }
60
64
  }
61
65
 
@@ -26,7 +26,11 @@ export function createCoverageFixtures(baseTest, config) {
26
26
  // eslint-disable-next-line react-hooks/rules-of-hooks
27
27
  await use(page)
28
28
  } finally {
29
- await tracker.stop(testInfo)
29
+ try {
30
+ await tracker.stop(testInfo)
31
+ } catch (error) {
32
+ console.warn("[coverage] failed to record V8 coverage:", error)
33
+ }
30
34
  }
31
35
  },
32
36
  })
@@ -3,11 +3,14 @@ import path from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
 
5
5
  import * as libCoverage from "istanbul-lib-coverage"
6
+ import * as libInstrument from "istanbul-lib-instrument"
6
7
  import { createContext } from "istanbul-lib-report"
7
8
  import reports from "istanbul-reports"
9
+ import fg from "fast-glob"
8
10
  import picomatch from "picomatch"
9
11
  import v8ToIstanbul from "v8-to-istanbul"
10
12
 
13
+ import { createCollectCoverageMatcher, toPosix } from "./collect.js"
11
14
  import { findCoverageFiles } from "./files.js"
12
15
 
13
16
 
@@ -30,6 +33,7 @@ export async function generateCoverageReport(config) {
30
33
 
31
34
  const coverageLib = resolveCoverageLib()
32
35
  const coverageMap = coverageLib.createCoverageMap({})
36
+ const matchesCollectCoverageFrom = createCollectCoverageMatcher(config.collectCoverageFrom, config.rootDir)
33
37
 
34
38
  for (const file of coverageFiles) {
35
39
  const payload = await readCoverageFile(file)
@@ -38,10 +42,14 @@ export async function generateCoverageReport(config) {
38
42
  }
39
43
 
40
44
  for (const script of payload.scripts) {
41
- await mergeScriptCoverage(coverageMap, script, config)
45
+ await mergeScriptCoverage(coverageMap, script, config, matchesCollectCoverageFrom)
42
46
  }
43
47
  }
44
48
 
49
+ if (config.includeAllFiles) {
50
+ await includeUntestedFiles(coverageMap, config, matchesCollectCoverageFrom)
51
+ }
52
+
45
53
  if (coverageMap.files().length === 0) {
46
54
  console.warn("[coverage] no library files matched the coverage filters")
47
55
  return
@@ -66,7 +74,7 @@ export async function generateCoverageReport(config) {
66
74
 
67
75
  const targets = Array.isArray(config.thresholdTargets) ? config.thresholdTargets : []
68
76
  if (targets.length > 0) {
69
- const fileSummaries = buildFileSummaries(coverageMap, config.workspaceRoot)
77
+ const fileSummaries = buildFileSummaries(coverageMap, config.rootDir)
70
78
  for (const target of targets) {
71
79
  const matcher = createGlobMatcher(target.pattern)
72
80
  const matchResult = collectTargetSummary(fileSummaries, matcher, coverageLib)
@@ -83,7 +91,7 @@ export async function generateCoverageReport(config) {
83
91
  }
84
92
  }
85
93
 
86
- async function mergeScriptCoverage(coverageMap, script, config) {
94
+ async function mergeScriptCoverage(coverageMap, script, config, matchesCollectCoverageFrom) {
87
95
  const scriptPath = script.absolutePath
88
96
  if (!scriptPath) {
89
97
  return
@@ -97,9 +105,21 @@ async function mergeScriptCoverage(coverageMap, script, config) {
97
105
  return
98
106
  }
99
107
 
100
- const source = script.source && script.source.length > 0
108
+ let source = script.source && script.source.length > 0
101
109
  ? script.source
102
- : await fs.readFile(scriptPath, "utf8")
110
+ : ""
111
+
112
+ if (!source) {
113
+ try {
114
+ source = await fs.readFile(scriptPath, "utf8")
115
+ } catch (error) {
116
+ const base = path.basename(scriptPath)
117
+ if (error?.code === "ENOENT" && base && !base.includes(".")) {
118
+ return
119
+ }
120
+ throw error
121
+ }
122
+ }
103
123
 
104
124
  const sourceMap = await loadSourceMapForScript(scriptPath, source)
105
125
  const converter = v8ToIstanbul(
@@ -110,7 +130,7 @@ async function mergeScriptCoverage(coverageMap, script, config) {
110
130
  await converter.load()
111
131
  converter.applyCoverage(script.functions)
112
132
 
113
- const filtered = filterCoverageMap(converter.toIstanbul(), config)
133
+ const filtered = filterCoverageMap(converter.toIstanbul(), config, matchesCollectCoverageFrom)
114
134
  if (Object.keys(filtered).length > 0) {
115
135
  coverageMap.merge(filtered)
116
136
  }
@@ -161,19 +181,156 @@ function resolveCoverageLib() {
161
181
  throw new Error("istanbul-lib-coverage exports are unavailable")
162
182
  }
163
183
 
164
- function buildFileSummaries(coverageMap, workspaceRoot) {
165
- const normalizedRoot = path.resolve(workspaceRoot)
184
+ function resolveInstrumentLib() {
185
+ const candidate = libInstrument
186
+ if (typeof candidate.createInstrumenter === "function") {
187
+ return candidate
188
+ }
189
+
190
+ if (candidate.default && typeof candidate.default.createInstrumenter === "function") {
191
+ return candidate.default
192
+ }
193
+
194
+ throw new Error("istanbul-lib-instrument exports are unavailable")
195
+ }
196
+
197
+ async function includeUntestedFiles(coverageMap, config, matchesCollectCoverageFrom) {
198
+ const existing = new Set(
199
+ coverageMap.files().map((filePath) => path.resolve(String(filePath ?? ""))),
200
+ )
201
+
202
+ const candidates = await findCollectCoverageFiles(config, matchesCollectCoverageFrom)
203
+ if (candidates.length === 0) {
204
+ return
205
+ }
206
+
207
+ const instrumentLib = resolveInstrumentLib()
208
+ const instrumenter = instrumentLib.createInstrumenter({
209
+ esModules: true,
210
+ parserPlugins: [
211
+ "typescript",
212
+ "jsx",
213
+ "classProperties",
214
+ "classPrivateProperties",
215
+ "classPrivateMethods",
216
+ "decorators-legacy",
217
+ "importMeta",
218
+ "topLevelAwait",
219
+ ],
220
+ })
221
+
222
+ for (const filePath of candidates) {
223
+ const normalized = path.resolve(filePath)
224
+ if (existing.has(normalized)) {
225
+ continue
226
+ }
227
+
228
+ const source = await fs.readFile(normalized, "utf8").catch(() => null)
229
+ if (source === null) {
230
+ continue
231
+ }
232
+
233
+ try {
234
+ instrumenter.instrumentSync(source, normalized)
235
+ const fileCoverage = instrumenter.lastFileCoverage()
236
+ if (!fileCoverage) {
237
+ continue
238
+ }
239
+ coverageMap.addFileCoverage(fileCoverage)
240
+ existing.add(normalized)
241
+ } catch (error) {
242
+ const relative = path.relative(config.rootDir, normalized)
243
+ console.warn(
244
+ `[coverage] failed to instrument ${relative && !relative.startsWith("..") ? relative : normalized}:`,
245
+ error,
246
+ )
247
+ }
248
+ }
249
+ }
250
+
251
+ const DEFAULT_COLLECT_COVERAGE_IGNORES = [
252
+ "**/.git/**",
253
+ "**/.next/**",
254
+ "**/.turbo/**",
255
+ "**/.vite/**",
256
+ "**/.vitest/**",
257
+ "**/build/**",
258
+ "**/coverage/**",
259
+ "**/dist/**",
260
+ "**/node_modules/**",
261
+ "**/playwright-report/**",
262
+ "**/spec/**",
263
+ "**/test/**",
264
+ "**/test-results/**",
265
+ "**/tests/**",
266
+ "**/__tests__/**",
267
+ "**/*.d.ts",
268
+ "**/*.map",
269
+ ]
270
+
271
+ async function findCollectCoverageFiles(config, matchesCollectCoverageFrom) {
272
+ const patterns = Array.isArray(config.collectCoverageFrom)
273
+ ? config.collectCoverageFrom
274
+ : []
275
+
276
+ if (patterns.length === 0) {
277
+ return []
278
+ }
279
+
280
+ const rawFiles = await fg(patterns, {
281
+ cwd: config.rootDir,
282
+ absolute: true,
283
+ dot: true,
284
+ onlyFiles: true,
285
+ unique: true,
286
+ followSymbolicLinks: false,
287
+ ignore: DEFAULT_COLLECT_COVERAGE_IGNORES,
288
+ }).catch(() => [])
289
+
290
+ const collected = new Set()
291
+
292
+ for (const file of rawFiles) {
293
+ const normalized = path.resolve(String(file ?? ""))
294
+ if (!normalized) {
295
+ continue
296
+ }
297
+
298
+ if (normalized.endsWith(".d.ts") || normalized.endsWith(".map")) {
299
+ continue
300
+ }
301
+
302
+ if (isNodeModulesPath(normalized)) {
303
+ continue
304
+ }
305
+
306
+ if (isViteVirtualModulePath(normalized)) {
307
+ continue
308
+ }
309
+
310
+ if (!matchesCollectCoverageFrom(normalized)) {
311
+ continue
312
+ }
313
+
314
+ collected.add(normalized)
315
+ }
316
+
317
+ return Array.from(collected).sort()
318
+ }
319
+
320
+ function buildFileSummaries(coverageMap, rootDir) {
321
+ const normalizedRoot = path.resolve(rootDir)
166
322
  return coverageMap.files().map((filePath) => {
167
323
  const normalizedAbsolute = path.resolve(filePath)
168
324
  const summary = coverageMap.fileCoverageFor(filePath).toSummary()
169
325
  const relativePath = path.relative(normalizedRoot, normalizedAbsolute)
170
- const candidates = new Set([toPosix(normalizedAbsolute)])
326
+ const candidates = new Set()
171
327
 
172
- if (relativePath && !relativePath.startsWith("..")) {
328
+ if (relativePath) {
173
329
  const relativePosix = toPosix(relativePath)
174
330
  candidates.add(relativePosix)
175
331
  candidates.add(`./${relativePosix}`)
176
- candidates.add(`/${relativePosix}`)
332
+ } else {
333
+ candidates.add(toPosix(path.basename(normalizedAbsolute)))
177
334
  }
178
335
 
179
336
  return {
@@ -202,11 +359,27 @@ function createGlobMatcher(pattern) {
202
359
  if (!normalized) {
203
360
  return () => false
204
361
  }
362
+ if (isAbsoluteGlobPattern(normalized)) {
363
+ throw new Error(`[coverage] threshold patterns must be relative (absolute paths are not supported): "${pattern}"`)
364
+ }
205
365
  return picomatch(normalized, { dot: true })
206
366
  }
207
367
 
208
- function toPosix(input) {
209
- return input.split(path.sep).join("/")
368
+ function isAbsoluteGlobPattern(pattern) {
369
+ const normalized = String(pattern ?? "").trim()
370
+ if (!normalized) {
371
+ return false
372
+ }
373
+
374
+ if (normalized.startsWith("/")) {
375
+ return true
376
+ }
377
+
378
+ if (normalized.startsWith("file://")) {
379
+ return true
380
+ }
381
+
382
+ return /^[A-Za-z]:\//.test(normalized)
210
383
  }
211
384
 
212
385
  function stripQuery(url) {
@@ -252,7 +425,7 @@ function resolveSourceMapPath(scriptPath, mappingUrl) {
252
425
  return path.resolve(path.dirname(scriptPath), cleaned)
253
426
  }
254
427
 
255
- function filterCoverageMap(map, config) {
428
+ function filterCoverageMap(map, config, matchesCollectCoverageFrom) {
256
429
  if (!map || typeof map !== "object") {
257
430
  return {}
258
431
  }
@@ -260,33 +433,38 @@ function filterCoverageMap(map, config) {
260
433
  const filtered = {}
261
434
 
262
435
  for (const [filePath, fileCoverage] of Object.entries(map)) {
263
- if (!isLibraryFile(filePath, config)) {
436
+ const absolutePath = resolveCoveragePath(filePath, config.rootDir)
437
+ if (!absolutePath) {
264
438
  continue
265
439
  }
266
- filtered[filePath] = fileCoverage
267
- }
268
440
 
269
- return filtered
270
- }
441
+ if (absolutePath.endsWith(".d.ts") || absolutePath.endsWith(".map")) {
442
+ continue
443
+ }
271
444
 
272
- function isLibraryFile(filePath, config) {
273
- const absolutePath = resolveCoveragePath(filePath, config.workspaceRoot)
274
- if (!absolutePath) {
275
- return false
276
- }
445
+ if (isNodeModulesPath(absolutePath)) {
446
+ continue
447
+ }
277
448
 
278
- if (isNodeModulesPath(absolutePath)) {
279
- return false
280
- }
449
+ if (isViteVirtualModulePath(absolutePath)) {
450
+ continue
451
+ }
281
452
 
282
- if (isViteVirtualModulePath(absolutePath)) {
283
- return false
453
+ if (!matchesCollectCoverageFrom(absolutePath)) {
454
+ continue
455
+ }
456
+
457
+ if (fileCoverage && typeof fileCoverage === "object") {
458
+ filtered[absolutePath] = { ...fileCoverage, path: absolutePath }
459
+ } else {
460
+ filtered[absolutePath] = fileCoverage
461
+ }
284
462
  }
285
463
 
286
- return isInsideLib(absolutePath, config.libRoots)
464
+ return filtered
287
465
  }
288
466
 
289
- function resolveCoveragePath(filePath, workspaceRoot) {
467
+ function resolveCoveragePath(filePath, rootDir) {
290
468
  const raw = String(filePath ?? "").trim()
291
469
  if (!raw) {
292
470
  return null
@@ -310,7 +488,7 @@ function resolveCoveragePath(filePath, workspaceRoot) {
310
488
  return null
311
489
  }
312
490
 
313
- return path.normalize(path.resolve(workspaceRoot, cleaned))
491
+ return path.normalize(path.resolve(rootDir, cleaned))
314
492
  }
315
493
 
316
494
  function isNodeModulesPath(filePath) {
@@ -328,13 +506,6 @@ function isViteVirtualModulePath(filePath) {
328
506
  || baseName.startsWith("__vite-")
329
507
  }
330
508
 
331
- function isInsideLib(absolutePath, libRoots) {
332
- return libRoots.some((libRoot) => {
333
- const relative = path.relative(libRoot, absolutePath)
334
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
335
- })
336
- }
337
-
338
509
  function parseSourceMapPayload(raw) {
339
510
  if (!raw || typeof raw !== "object") {
340
511
  return null
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises"
2
2
  import path from "node:path"
3
3
 
4
+ import { isInsideAnyRoot, resolveCollectCoverageRoots } from "./collect.js"
5
+
4
6
 
5
7
  const VITE_FS_PREFIX = "/@fs/"
6
8
 
@@ -9,10 +11,16 @@ function sanitizePathSegment(input) {
9
11
  return value.replace(/[^a-zA-Z0-9._-]+/g, "_") || "unknown"
10
12
  }
11
13
 
14
+ function urlPathLooksLikeFile(pathname) {
15
+ const base = path.posix.basename(String(pathname ?? ""))
16
+ return base.length > 0 && base.includes(".")
17
+ }
18
+
12
19
  export async function createCoverageTracker(page, config) {
13
20
  const session = await page.context().newCDPSession(page)
14
21
  const scriptMeta = new Map()
15
22
  const sourceCache = new Map()
23
+ const scriptRoots = resolveCollectCoverageRoots(config.collectCoverageFrom, config.rootDir)
16
24
 
17
25
  await session.send("Debugger.enable")
18
26
  session.on("Debugger.scriptParsed", (event) => {
@@ -21,16 +29,14 @@ export async function createCoverageTracker(page, config) {
21
29
  }
22
30
 
23
31
  const normalized = normalizeScriptUrl(event.url, config)
24
- const trackable = normalized && isInsideLib(normalized.absolutePath, config.libRoots)
32
+ const trackable = normalized
33
+ && !isNodeModulesPath(normalized.absolutePath)
34
+ && isInsideAnyRoot(normalized.absolutePath, scriptRoots)
25
35
 
26
36
  scriptMeta.set(event.scriptId, {
27
37
  normalized: trackable ? normalized : null,
28
38
  url: event.url,
29
39
  })
30
-
31
- if (trackable && !sourceCache.has(event.scriptId)) {
32
- sourceCache.set(event.scriptId, fetchScriptSource(session, event.scriptId))
33
- }
34
40
  })
35
41
 
36
42
  await session.send("Profiler.enable")
@@ -102,8 +108,23 @@ async function resolveScriptSource(session, cache, scriptId) {
102
108
  }
103
109
 
104
110
  async function fetchScriptSource(session, scriptId) {
105
- const sourceResponse = await session.send("Debugger.getScriptSource", { scriptId })
106
- return sourceResponse?.scriptSource ?? ""
111
+ try {
112
+ const sourceResponse = await session.send("Debugger.getScriptSource", { scriptId })
113
+ return sourceResponse?.scriptSource ?? ""
114
+ } catch (error) {
115
+ const message = String(error?.message ?? error)
116
+ if (message.includes("Debugger agent is not enabled")) {
117
+ try {
118
+ await session.send("Debugger.enable")
119
+ const sourceResponse = await session.send("Debugger.getScriptSource", { scriptId })
120
+ return sourceResponse?.scriptSource ?? ""
121
+ } catch {
122
+ return ""
123
+ }
124
+ }
125
+
126
+ return ""
127
+ }
107
128
  }
108
129
 
109
130
  async function shutdownSession(session) {
@@ -140,22 +161,22 @@ function normalizeScriptUrl(rawUrl, config) {
140
161
  if (decoded.startsWith(VITE_FS_PREFIX)) {
141
162
  absolutePath = path.normalize(decoded.slice(VITE_FS_PREFIX.length))
142
163
  } else if (decoded.startsWith("/")) {
164
+ if (!urlPathLooksLikeFile(decoded)) {
165
+ return null
166
+ }
143
167
  absolutePath = path.resolve(process.cwd(), `.${decoded}`)
144
168
  } else {
145
169
  return null
146
170
  }
147
171
 
148
- return createNormalizedPath(absolutePath, config.workspaceRoot)
172
+ return createNormalizedPath(absolutePath, config.rootDir)
149
173
  }
150
174
 
151
- function createNormalizedPath(absolutePath, workspaceRoot) {
152
- if (!absolutePath.startsWith(workspaceRoot)) {
153
- return null
154
- }
155
-
156
- const relativePath = path.relative(workspaceRoot, absolutePath)
175
+ function createNormalizedPath(absolutePath, rootDir) {
176
+ const normalizedAbsolute = path.normalize(absolutePath)
177
+ const relativePath = path.relative(rootDir, normalizedAbsolute)
157
178
  return {
158
- absolutePath,
179
+ absolutePath: normalizedAbsolute,
159
180
  relativePath,
160
181
  }
161
182
  }
@@ -176,9 +197,9 @@ function stripQuery(url) {
176
197
  return url.slice(0, endIndex)
177
198
  }
178
199
 
179
- function isInsideLib(absolutePath, libRoots) {
180
- return libRoots.some((libRoot) => {
181
- const relative = path.relative(libRoot, absolutePath)
182
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
183
- })
200
+ function isNodeModulesPath(filePath) {
201
+ return path
202
+ .normalize(String(filePath ?? ""))
203
+ .split(path.sep)
204
+ .includes("node_modules")
184
205
  }