@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 +16 -13
- package/index.d.ts +4 -3
- package/package.json +3 -1
- package/src/cli.js +66 -22
- package/src/coverage/collect.js +134 -0
- package/src/coverage/config-loader.js +92 -12
- package/src/coverage/config.js +17 -13
- package/src/coverage/fixtures.js +5 -1
- package/src/coverage/global-setup.js +4 -0
- package/src/coverage/report.js +422 -14
- package/src/coverage/reporter.js +8 -0
- package/src/coverage/v8-tracker.js +55 -22
- package/src/index.js +1 -1
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
|
-
"
|
|
47
|
+
"src/core/**": {
|
|
45
48
|
statements: 98,
|
|
46
49
|
lines: 95,
|
|
47
50
|
},
|
|
48
|
-
"
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
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.
|
|
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 (
|
|
35
|
-
await cleanCoverageArtifacts(
|
|
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 (
|
|
52
|
+
if (combinedCoverage?.enabled) {
|
|
49
53
|
try {
|
|
50
|
-
await finalizeCoverage(
|
|
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(() =>
|
|
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.
|
|
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(
|
|
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.
|
|
319
|
+
const normalized = normalizeNodeScriptUrl(script.url, config.rootDir)
|
|
277
320
|
if (!normalized) {
|
|
278
321
|
continue
|
|
279
322
|
}
|
|
280
323
|
|
|
281
|
-
if (
|
|
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,
|
|
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(
|
|
401
|
+
relativePath: path.relative(rootDir, normalized),
|
|
358
402
|
}
|
|
359
403
|
}
|
|
360
404
|
|
|
361
|
-
function
|
|
362
|
-
return
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
}
|
package/src/coverage/config.js
CHANGED
|
@@ -23,22 +23,25 @@ function resolveDir(root, target, fallback) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function createCoverageConfig(options = {}) {
|
|
26
|
-
const {
|
|
27
|
-
if (!
|
|
28
|
-
throw new Error("createCoverageConfig requires a
|
|
26
|
+
const { rootDir } = options
|
|
27
|
+
if (!rootDir) {
|
|
28
|
+
throw new Error("createCoverageConfig requires a rootDir")
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const
|
|
31
|
+
const resolvedRootDir = path.resolve(rootDir)
|
|
32
|
+
const includeAllFiles = options.includeAllFiles !== false
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
-
? options.
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (collectCoverageFrom.length === 0) {
|
|
41
|
+
throw new Error("createCoverageConfig requires a collectCoverageFrom option")
|
|
42
|
+
}
|
|
40
43
|
|
|
41
|
-
const testResultsRoot = resolveDir(
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
package/src/coverage/fixtures.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/coverage/report.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
|
147
|
-
const
|
|
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(
|
|
326
|
+
const candidates = new Set()
|
|
153
327
|
|
|
154
|
-
if (relativePath
|
|
328
|
+
if (relativePath) {
|
|
155
329
|
const relativePosix = toPosix(relativePath)
|
|
156
330
|
candidates.add(relativePosix)
|
|
157
331
|
candidates.add(`./${relativePosix}`)
|
|
158
|
-
|
|
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
|
|
191
|
-
|
|
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
|
}
|
package/src/coverage/reporter.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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.
|
|
172
|
+
return createNormalizedPath(absolutePath, config.rootDir)
|
|
137
173
|
}
|
|
138
174
|
|
|
139
|
-
function createNormalizedPath(absolutePath,
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
|
|
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)
|