@rpcbase/test 0.304.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.304.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"
@@ -19,6 +20,7 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url))
19
20
 
20
21
  const VITEST_COVERAGE_CANDIDATES = ["src/coverage.json"]
21
22
 
23
+ const COMBINED_COVERAGE_ENV_VAR = "RB_TEST_COMBINED_COVERAGE"
22
24
 
23
25
  const isAider = process.env.IS_AIDER === "yes"
24
26
 
@@ -29,25 +31,27 @@ if (process.env.IS_AIDER !== undefined && process.env.IS_AIDER !== "yes") {
29
31
  async function runTests() {
30
32
  const userArgs = process.argv.slice(2)
31
33
 
34
+ const playwrightCoverage = await loadPlaywrightCoverageConfig()
32
35
  const vitestCoverage = await loadVitestCoverageConfig()
36
+ const combinedCoverage = resolveCombinedCoverage(playwrightCoverage, vitestCoverage)
33
37
 
34
- if (vitestCoverage?.enabled) {
35
- await cleanCoverageArtifacts(vitestCoverage.config)
38
+ if (combinedCoverage?.enabled) {
39
+ await cleanCoverageArtifacts(combinedCoverage.config)
36
40
  }
37
41
 
38
42
  let testError = null
39
43
 
40
44
  try {
41
- await runVitest(vitestCoverage)
45
+ await runVitest(vitestCoverage, combinedCoverage?.config ?? null)
42
46
  console.log("\nRunning Playwright Tests...")
43
47
  await runPlaywright(userArgs)
44
48
  } catch (error) {
45
49
  testError = error
46
50
  }
47
51
 
48
- if (vitestCoverage?.enabled) {
52
+ if (combinedCoverage?.enabled) {
49
53
  try {
50
- await finalizeCoverage(vitestCoverage.config)
54
+ await finalizeCoverage(combinedCoverage.config)
51
55
  } catch (error) {
52
56
  if (!testError) {
53
57
  testError = error
@@ -62,9 +66,14 @@ async function runTests() {
62
66
 
63
67
  runTests()
64
68
  .then(() => process.exit(0))
65
- .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
+ })
66
75
 
67
- async function runVitest(coverage) {
76
+ async function runVitest(coverage, combinedConfig) {
68
77
  const vitestArgs = ["run", "--passWithNoTests"]
69
78
  const vitestConfig = resolveVitestConfig()
70
79
 
@@ -75,7 +84,7 @@ async function runVitest(coverage) {
75
84
  const env = withRegisterShim(process.env)
76
85
 
77
86
  if (coverage?.enabled) {
78
- env.NODE_V8_COVERAGE = coverage.nodeCoverageDir
87
+ env.NODE_V8_COVERAGE = resolveNodeCoverageDir(combinedConfig ?? coverage.config)
79
88
  }
80
89
 
81
90
  await spawnWithLogs({
@@ -88,7 +97,10 @@ async function runVitest(coverage) {
88
97
  })
89
98
 
90
99
  if (coverage?.enabled) {
91
- await convertNodeCoverage(coverage)
100
+ await convertNodeCoverage({
101
+ config: combinedConfig ?? coverage.config,
102
+ nodeCoverageDir: resolveNodeCoverageDir(combinedConfig ?? coverage.config),
103
+ })
92
104
  }
93
105
  }
94
106
 
@@ -119,6 +131,7 @@ function runPlaywright(userArgs) {
119
131
  ensureJsxRuntimeShim(process.cwd())
120
132
  const launcher = resolvePlaywrightLauncher()
121
133
  const env = withRegisterShim(process.env)
134
+ env[COMBINED_COVERAGE_ENV_VAR] = "1"
122
135
 
123
136
  return spawnWithLogs({
124
137
  name: "Playwright",
@@ -246,22 +259,52 @@ async function loadVitestCoverageConfig() {
246
259
 
247
260
  return {
248
261
  config,
249
- nodeCoverageDir: path.join(config.testResultsRoot, "node-coverage"),
250
262
  enabled: config.coverageEnabled,
251
263
  }
252
264
  }
253
265
 
266
+ async function loadPlaywrightCoverageConfig() {
267
+ const options = await loadCoverageOptions({ optional: true })
268
+ if (!options) {
269
+ return null
270
+ }
271
+
272
+ const config = createCoverageConfig(options)
273
+
274
+ return {
275
+ config,
276
+ enabled: config.coverageEnabled,
277
+ }
278
+ }
279
+
280
+ function resolveCombinedCoverage(playwrightCoverage, vitestCoverage) {
281
+ if (playwrightCoverage?.enabled) {
282
+ return playwrightCoverage
283
+ }
284
+
285
+ if (vitestCoverage?.enabled) {
286
+ return vitestCoverage
287
+ }
288
+
289
+ return null
290
+ }
291
+
254
292
  async function cleanCoverageArtifacts(config) {
255
293
  await removeCoverageFiles(config)
256
294
  await fsPromises.rm(config.coverageReportDir, { recursive: true, force: true })
257
295
  await fsPromises.rm(path.join(config.testResultsRoot, "node-coverage"), { recursive: true, force: true })
258
296
  }
259
297
 
298
+ function resolveNodeCoverageDir(config) {
299
+ return path.join(config.testResultsRoot, "node-coverage", "vitest")
300
+ }
301
+
260
302
  async function convertNodeCoverage(coverage) {
261
303
  const { config, nodeCoverageDir } = coverage
262
304
 
263
305
  const entries = await fsPromises.readdir(nodeCoverageDir).catch(() => [])
264
306
  const scripts = []
307
+ const scriptRoots = resolveCollectCoverageRoots(config.collectCoverageFrom, config.rootDir)
265
308
 
266
309
  for (const entry of entries) {
267
310
  if (!entry.endsWith(".json")) {
@@ -273,12 +316,16 @@ async function convertNodeCoverage(coverage) {
273
316
  const results = Array.isArray(payload?.result) ? payload.result : []
274
317
 
275
318
  for (const script of results) {
276
- const normalized = normalizeNodeScriptUrl(script.url, config.workspaceRoot)
319
+ const normalized = normalizeNodeScriptUrl(script.url, config.rootDir)
277
320
  if (!normalized) {
278
321
  continue
279
322
  }
280
323
 
281
- if (!isInsideLib(normalized.absolutePath, config.libRoots)) {
324
+ if (isNodeModulesPath(normalized.absolutePath)) {
325
+ continue
326
+ }
327
+
328
+ if (!isInsideAnyRoot(normalized.absolutePath, scriptRoots)) {
282
329
  continue
283
330
  }
284
331
 
@@ -324,7 +371,7 @@ async function readJson(filePath) {
324
371
  }
325
372
  }
326
373
 
327
- function normalizeNodeScriptUrl(rawUrl, workspaceRoot) {
374
+ function normalizeNodeScriptUrl(rawUrl, rootDir) {
328
375
  if (!rawUrl || rawUrl.startsWith("node:")) {
329
376
  return null
330
377
  }
@@ -348,21 +395,18 @@ function normalizeNodeScriptUrl(rawUrl, workspaceRoot) {
348
395
  }
349
396
 
350
397
  const normalized = path.normalize(absolutePath)
351
- if (!normalized.startsWith(workspaceRoot)) {
352
- return null
353
- }
354
398
 
355
399
  return {
356
400
  absolutePath: normalized,
357
- relativePath: path.relative(workspaceRoot, normalized),
401
+ relativePath: path.relative(rootDir, normalized),
358
402
  }
359
403
  }
360
404
 
361
- function isInsideLib(absolutePath, libRoots) {
362
- return libRoots.some((libRoot) => {
363
- const relative = path.relative(libRoot, absolutePath)
364
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
365
- })
405
+ function isNodeModulesPath(filePath) {
406
+ return path
407
+ .normalize(String(filePath ?? ""))
408
+ .split(path.sep)
409
+ .includes("node_modules")
366
410
  }
367
411
 
368
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
  })
@@ -5,6 +5,10 @@ import { removeCoverageFiles } from "./files.js"
5
5
 
6
6
  export function createCoverageGlobalSetup(config) {
7
7
  return async function globalSetup() {
8
+ if (process.env.RB_TEST_COMBINED_COVERAGE === "1") {
9
+ return
10
+ }
11
+
8
12
  if (!config.coverageEnabled) {
9
13
  return
10
14
  }
@@ -1,12 +1,16 @@
1
1
  import fs from "node:fs/promises"
2
2
  import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
3
4
 
4
5
  import * as libCoverage from "istanbul-lib-coverage"
6
+ import * as libInstrument from "istanbul-lib-instrument"
5
7
  import { createContext } from "istanbul-lib-report"
6
8
  import reports from "istanbul-reports"
9
+ import fg from "fast-glob"
7
10
  import picomatch from "picomatch"
8
11
  import v8ToIstanbul from "v8-to-istanbul"
9
12
 
13
+ import { createCollectCoverageMatcher, toPosix } from "./collect.js"
10
14
  import { findCoverageFiles } from "./files.js"
11
15
 
12
16
 
@@ -29,6 +33,7 @@ export async function generateCoverageReport(config) {
29
33
 
30
34
  const coverageLib = resolveCoverageLib()
31
35
  const coverageMap = coverageLib.createCoverageMap({})
36
+ const matchesCollectCoverageFrom = createCollectCoverageMatcher(config.collectCoverageFrom, config.rootDir)
32
37
 
33
38
  for (const file of coverageFiles) {
34
39
  const payload = await readCoverageFile(file)
@@ -37,10 +42,14 @@ export async function generateCoverageReport(config) {
37
42
  }
38
43
 
39
44
  for (const script of payload.scripts) {
40
- await mergeScriptCoverage(coverageMap, script)
45
+ await mergeScriptCoverage(coverageMap, script, config, matchesCollectCoverageFrom)
41
46
  }
42
47
  }
43
48
 
49
+ if (config.includeAllFiles) {
50
+ await includeUntestedFiles(coverageMap, config, matchesCollectCoverageFrom)
51
+ }
52
+
44
53
  if (coverageMap.files().length === 0) {
45
54
  console.warn("[coverage] no library files matched the coverage filters")
46
55
  return
@@ -65,7 +74,7 @@ export async function generateCoverageReport(config) {
65
74
 
66
75
  const targets = Array.isArray(config.thresholdTargets) ? config.thresholdTargets : []
67
76
  if (targets.length > 0) {
68
- const fileSummaries = buildFileSummaries(coverageMap, config.workspaceRoot)
77
+ const fileSummaries = buildFileSummaries(coverageMap, config.rootDir)
69
78
  for (const target of targets) {
70
79
  const matcher = createGlobMatcher(target.pattern)
71
80
  const matchResult = collectTargetSummary(fileSummaries, matcher, coverageLib)
@@ -82,20 +91,49 @@ export async function generateCoverageReport(config) {
82
91
  }
83
92
  }
84
93
 
85
- async function mergeScriptCoverage(coverageMap, script) {
94
+ async function mergeScriptCoverage(coverageMap, script, config, matchesCollectCoverageFrom) {
86
95
  const scriptPath = script.absolutePath
87
96
  if (!scriptPath) {
88
97
  return
89
98
  }
90
99
 
91
- const source = script.source && script.source.length > 0
100
+ if (isNodeModulesPath(scriptPath)) {
101
+ return
102
+ }
103
+
104
+ if (isViteVirtualModulePath(scriptPath)) {
105
+ return
106
+ }
107
+
108
+ let source = script.source && script.source.length > 0
92
109
  ? script.source
93
- : 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
+ }
94
123
 
95
- const converter = v8ToIstanbul(scriptPath, 0, { source })
124
+ const sourceMap = await loadSourceMapForScript(scriptPath, source)
125
+ const converter = v8ToIstanbul(
126
+ scriptPath,
127
+ 0,
128
+ sourceMap ? { source, sourceMap: { sourcemap: sourceMap } } : { source },
129
+ )
96
130
  await converter.load()
97
131
  converter.applyCoverage(script.functions)
98
- coverageMap.merge(converter.toIstanbul())
132
+
133
+ const filtered = filterCoverageMap(converter.toIstanbul(), config, matchesCollectCoverageFrom)
134
+ if (Object.keys(filtered).length > 0) {
135
+ coverageMap.merge(filtered)
136
+ }
99
137
  }
100
138
 
101
139
  async function readCoverageFile(file) {
@@ -143,19 +181,156 @@ function resolveCoverageLib() {
143
181
  throw new Error("istanbul-lib-coverage exports are unavailable")
144
182
  }
145
183
 
146
- function buildFileSummaries(coverageMap, workspaceRoot) {
147
- 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)
148
322
  return coverageMap.files().map((filePath) => {
149
323
  const normalizedAbsolute = path.resolve(filePath)
150
324
  const summary = coverageMap.fileCoverageFor(filePath).toSummary()
151
325
  const relativePath = path.relative(normalizedRoot, normalizedAbsolute)
152
- const candidates = new Set([toPosix(normalizedAbsolute)])
326
+ const candidates = new Set()
153
327
 
154
- if (relativePath && !relativePath.startsWith("..")) {
328
+ if (relativePath) {
155
329
  const relativePosix = toPosix(relativePath)
156
330
  candidates.add(relativePosix)
157
331
  candidates.add(`./${relativePosix}`)
158
- candidates.add(`/${relativePosix}`)
332
+ } else {
333
+ candidates.add(toPosix(path.basename(normalizedAbsolute)))
159
334
  }
160
335
 
161
336
  return {
@@ -184,9 +359,242 @@ function createGlobMatcher(pattern) {
184
359
  if (!normalized) {
185
360
  return () => false
186
361
  }
362
+ if (isAbsoluteGlobPattern(normalized)) {
363
+ throw new Error(`[coverage] threshold patterns must be relative (absolute paths are not supported): "${pattern}"`)
364
+ }
187
365
  return picomatch(normalized, { dot: true })
188
366
  }
189
367
 
190
- function toPosix(input) {
191
- 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)
383
+ }
384
+
385
+ function stripQuery(url) {
386
+ const queryIndex = url.indexOf("?")
387
+ const hashIndex = url.indexOf("#")
388
+
389
+ const endIndex = Math.min(
390
+ queryIndex === -1 ? Number.POSITIVE_INFINITY : queryIndex,
391
+ hashIndex === -1 ? Number.POSITIVE_INFINITY : hashIndex,
392
+ )
393
+
394
+ if (!Number.isFinite(endIndex)) {
395
+ return url
396
+ }
397
+
398
+ return url.slice(0, endIndex)
399
+ }
400
+
401
+ function extractSourceMappingUrl(source) {
402
+ const regex = /\/\/[#@]\s*sourceMappingURL=([^\s]+)/g
403
+
404
+ let last = null
405
+ let match = null
406
+
407
+ while ((match = regex.exec(source)) !== null) {
408
+ last = match[1]
409
+ }
410
+
411
+ return typeof last === "string" && last.length > 0 ? last : null
412
+ }
413
+
414
+ function resolveSourceMapPath(scriptPath, mappingUrl) {
415
+ const cleaned = stripQuery(mappingUrl)
416
+
417
+ if (cleaned.startsWith("file://")) {
418
+ return fileURLToPath(cleaned)
419
+ }
420
+
421
+ if (path.isAbsolute(cleaned)) {
422
+ return cleaned
423
+ }
424
+
425
+ return path.resolve(path.dirname(scriptPath), cleaned)
426
+ }
427
+
428
+ function filterCoverageMap(map, config, matchesCollectCoverageFrom) {
429
+ if (!map || typeof map !== "object") {
430
+ return {}
431
+ }
432
+
433
+ const filtered = {}
434
+
435
+ for (const [filePath, fileCoverage] of Object.entries(map)) {
436
+ const absolutePath = resolveCoveragePath(filePath, config.rootDir)
437
+ if (!absolutePath) {
438
+ continue
439
+ }
440
+
441
+ if (absolutePath.endsWith(".d.ts") || absolutePath.endsWith(".map")) {
442
+ continue
443
+ }
444
+
445
+ if (isNodeModulesPath(absolutePath)) {
446
+ continue
447
+ }
448
+
449
+ if (isViteVirtualModulePath(absolutePath)) {
450
+ continue
451
+ }
452
+
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
+ }
462
+ }
463
+
464
+ return filtered
465
+ }
466
+
467
+ function resolveCoveragePath(filePath, rootDir) {
468
+ const raw = String(filePath ?? "").trim()
469
+ if (!raw) {
470
+ return null
471
+ }
472
+
473
+ const cleaned = stripQuery(raw)
474
+
475
+ if (cleaned.startsWith("file://")) {
476
+ try {
477
+ return path.normalize(fileURLToPath(cleaned))
478
+ } catch {
479
+ return null
480
+ }
481
+ }
482
+
483
+ if (path.isAbsolute(cleaned)) {
484
+ return path.normalize(cleaned)
485
+ }
486
+
487
+ if (cleaned.includes("://")) {
488
+ return null
489
+ }
490
+
491
+ return path.normalize(path.resolve(rootDir, cleaned))
492
+ }
493
+
494
+ function isNodeModulesPath(filePath) {
495
+ return path
496
+ .normalize(String(filePath ?? ""))
497
+ .split(path.sep)
498
+ .includes("node_modules")
499
+ }
500
+
501
+ function isViteVirtualModulePath(filePath) {
502
+ const normalized = path.normalize(String(filePath ?? ""))
503
+ const baseName = path.basename(normalized)
504
+ return baseName === "__vite-browser-external"
505
+ || baseName.startsWith("__vite-browser-external:")
506
+ || baseName.startsWith("__vite-")
507
+ }
508
+
509
+ function parseSourceMapPayload(raw) {
510
+ if (!raw || typeof raw !== "object") {
511
+ return null
512
+ }
513
+
514
+ const sources = raw.sources
515
+ if (!Array.isArray(sources) || sources.length === 0) {
516
+ return null
517
+ }
518
+
519
+ return raw
520
+ }
521
+
522
+ function normalizeSourceMap(sourceMap, scriptPath) {
523
+ const root = typeof sourceMap.sourceRoot === "string"
524
+ ? sourceMap.sourceRoot.replace("file://", "")
525
+ : ""
526
+
527
+ const dir = path.dirname(scriptPath)
528
+ const fixedSources = sourceMap.sources.map((source) => {
529
+ const raw = String(source ?? "")
530
+ const cleaned = stripQuery(raw)
531
+
532
+ if (cleaned.startsWith("file://")) {
533
+ try {
534
+ return path.normalize(fileURLToPath(cleaned))
535
+ } catch {
536
+ return cleaned
537
+ }
538
+ }
539
+
540
+ const withoutWebpack = cleaned.replace(/^webpack:\/\//, "")
541
+ const candidate = path.join(root, withoutWebpack)
542
+
543
+ if (path.isAbsolute(candidate)) {
544
+ return path.normalize(candidate)
545
+ }
546
+
547
+ const normalizedCandidate = candidate.split("/").join(path.sep)
548
+
549
+ if (dir.endsWith(`${path.sep}dist`) && !normalizedCandidate.startsWith(`..${path.sep}`)) {
550
+ if (normalizedCandidate.startsWith(`src${path.sep}`) || normalizedCandidate.startsWith(`lib${path.sep}`)) {
551
+ return path.normalize(path.resolve(dir, "..", normalizedCandidate))
552
+ }
553
+ }
554
+
555
+ return path.normalize(path.resolve(dir, normalizedCandidate))
556
+ })
557
+
558
+ return {
559
+ ...sourceMap,
560
+ sources: fixedSources,
561
+ }
562
+ }
563
+
564
+ async function loadSourceMapForScript(scriptPath, source) {
565
+ const mappingUrl = extractSourceMappingUrl(source)
566
+ if (!mappingUrl) {
567
+ return null
568
+ }
569
+
570
+ const cleaned = stripQuery(mappingUrl)
571
+
572
+ if (cleaned.startsWith("data:")) {
573
+ const commaIndex = cleaned.indexOf(",")
574
+ if (commaIndex === -1) {
575
+ return null
576
+ }
577
+
578
+ const meta = cleaned.slice(0, commaIndex)
579
+ const payload = cleaned.slice(commaIndex + 1)
580
+ const raw = meta.includes(";base64")
581
+ ? Buffer.from(payload, "base64").toString("utf8")
582
+ : decodeURIComponent(payload)
583
+
584
+ try {
585
+ const parsed = parseSourceMapPayload(JSON.parse(raw))
586
+ return parsed ? normalizeSourceMap(parsed, scriptPath) : null
587
+ } catch {
588
+ return null
589
+ }
590
+ }
591
+
592
+ try {
593
+ const mapPath = resolveSourceMapPath(scriptPath, cleaned)
594
+ const raw = await fs.readFile(mapPath, "utf8")
595
+ const parsed = parseSourceMapPayload(JSON.parse(raw))
596
+ return parsed ? normalizeSourceMap(parsed, scriptPath) : null
597
+ } catch {
598
+ return null
599
+ }
192
600
  }
@@ -13,6 +13,10 @@ class CoverageReporter {
13
13
  }
14
14
 
15
15
  async onBegin() {
16
+ if (process.env.RB_TEST_COMBINED_COVERAGE === "1") {
17
+ return
18
+ }
19
+
16
20
  if (!this.config.coverageEnabled) {
17
21
  return
18
22
  }
@@ -22,6 +26,10 @@ class CoverageReporter {
22
26
  }
23
27
 
24
28
  async onEnd(result) {
29
+ if (process.env.RB_TEST_COMBINED_COVERAGE === "1") {
30
+ return
31
+ }
32
+
25
33
  if (!this.config.coverageEnabled) {
26
34
  return
27
35
  }
@@ -1,13 +1,26 @@
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
+ function sanitizePathSegment(input) {
10
+ const value = String(input ?? "").trim()
11
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "_") || "unknown"
12
+ }
13
+
14
+ function urlPathLooksLikeFile(pathname) {
15
+ const base = path.posix.basename(String(pathname ?? ""))
16
+ return base.length > 0 && base.includes(".")
17
+ }
18
+
7
19
  export async function createCoverageTracker(page, config) {
8
20
  const session = await page.context().newCDPSession(page)
9
21
  const scriptMeta = new Map()
10
22
  const sourceCache = new Map()
23
+ const scriptRoots = resolveCollectCoverageRoots(config.collectCoverageFrom, config.rootDir)
11
24
 
12
25
  await session.send("Debugger.enable")
13
26
  session.on("Debugger.scriptParsed", (event) => {
@@ -16,16 +29,14 @@ export async function createCoverageTracker(page, config) {
16
29
  }
17
30
 
18
31
  const normalized = normalizeScriptUrl(event.url, config)
19
- const trackable = normalized && isInsideLib(normalized.absolutePath, config.libRoots)
32
+ const trackable = normalized
33
+ && !isNodeModulesPath(normalized.absolutePath)
34
+ && isInsideAnyRoot(normalized.absolutePath, scriptRoots)
20
35
 
21
36
  scriptMeta.set(event.scriptId, {
22
37
  normalized: trackable ? normalized : null,
23
38
  url: event.url,
24
39
  })
25
-
26
- if (trackable && !sourceCache.has(event.scriptId)) {
27
- sourceCache.set(event.scriptId, fetchScriptSource(session, event.scriptId))
28
- }
29
40
  })
30
41
 
31
42
  await session.send("Profiler.enable")
@@ -40,7 +51,7 @@ export async function createCoverageTracker(page, config) {
40
51
  return
41
52
  }
42
53
 
43
- const outputFile = testInfo.outputPath(config.coverageFileName)
54
+ const outputFile = resolveCoverageOutputFile(config, testInfo)
44
55
  await fs.mkdir(path.dirname(outputFile), { recursive: true })
45
56
  await fs.writeFile(outputFile, JSON.stringify(payload, null, 2), "utf8")
46
57
  } finally {
@@ -50,6 +61,13 @@ export async function createCoverageTracker(page, config) {
50
61
  }
51
62
  }
52
63
 
64
+ function resolveCoverageOutputFile(config, testInfo) {
65
+ const projectName = sanitizePathSegment(testInfo.project?.name)
66
+ const testId = sanitizePathSegment(testInfo.testId)
67
+ const outputDir = path.join(config.testResultsRoot, "playwright", projectName, testId)
68
+ return path.join(outputDir, config.coverageFileName)
69
+ }
70
+
53
71
  async function collectCoveragePayload(session, scriptMeta, sourceCache, testInfo, config) {
54
72
  const { result } = await session.send("Profiler.takePreciseCoverage")
55
73
  await session.send("Profiler.stopPreciseCoverage")
@@ -90,8 +108,23 @@ async function resolveScriptSource(session, cache, scriptId) {
90
108
  }
91
109
 
92
110
  async function fetchScriptSource(session, scriptId) {
93
- const sourceResponse = await session.send("Debugger.getScriptSource", { scriptId })
94
- 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
+ }
95
128
  }
96
129
 
97
130
  async function shutdownSession(session) {
@@ -128,22 +161,22 @@ function normalizeScriptUrl(rawUrl, config) {
128
161
  if (decoded.startsWith(VITE_FS_PREFIX)) {
129
162
  absolutePath = path.normalize(decoded.slice(VITE_FS_PREFIX.length))
130
163
  } else if (decoded.startsWith("/")) {
131
- absolutePath = path.resolve(config.workspaceRoot, `.${decoded}`)
164
+ if (!urlPathLooksLikeFile(decoded)) {
165
+ return null
166
+ }
167
+ absolutePath = path.resolve(process.cwd(), `.${decoded}`)
132
168
  } else {
133
169
  return null
134
170
  }
135
171
 
136
- return createNormalizedPath(absolutePath, config.workspaceRoot)
172
+ return createNormalizedPath(absolutePath, config.rootDir)
137
173
  }
138
174
 
139
- function createNormalizedPath(absolutePath, workspaceRoot) {
140
- if (!absolutePath.startsWith(workspaceRoot)) {
141
- return null
142
- }
143
-
144
- const relativePath = path.relative(workspaceRoot, absolutePath)
175
+ function createNormalizedPath(absolutePath, rootDir) {
176
+ const normalizedAbsolute = path.normalize(absolutePath)
177
+ const relativePath = path.relative(rootDir, normalizedAbsolute)
145
178
  return {
146
- absolutePath,
179
+ absolutePath: normalizedAbsolute,
147
180
  relativePath,
148
181
  }
149
182
  }
@@ -164,9 +197,9 @@ function stripQuery(url) {
164
197
  return url.slice(0, endIndex)
165
198
  }
166
199
 
167
- function isInsideLib(absolutePath, libRoots) {
168
- return libRoots.some((libRoot) => {
169
- const relative = path.relative(libRoot, absolutePath)
170
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
171
- })
200
+ function isNodeModulesPath(filePath) {
201
+ return path
202
+ .normalize(String(filePath ?? ""))
203
+ .split(path.sep)
204
+ .includes("node_modules")
172
205
  }
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ export function defineConfig(userConfig = {}) {
17
17
  const normalized = { ...userConfig }
18
18
  const reporters = ensureReporterArray(normalized.reporter)
19
19
 
20
- if (coverageHarness?.config.coverageEnabled) {
20
+ if (coverageHarness?.config.coverageEnabled && process.env.RB_TEST_COMBINED_COVERAGE !== "1") {
21
21
  const coverageReporter = coverageHarness.reporterEntry()
22
22
  if (!reporters.some(([name]) => name === coverageReporter[0])) {
23
23
  reporters.push(coverageReporter)