@rpcbase/test 0.305.0 → 0.306.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -13
- package/index.d.ts +4 -3
- package/package.json +3 -1
- package/src/cli.js +21 -13
- 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/report.js +210 -39
- package/src/coverage/v8-tracker.js +41 -20
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"
|
|
@@ -65,7 +66,12 @@ async function runTests() {
|
|
|
65
66
|
|
|
66
67
|
runTests()
|
|
67
68
|
.then(() => process.exit(0))
|
|
68
|
-
.catch(() =>
|
|
69
|
+
.catch((error) => {
|
|
70
|
+
if (!(error instanceof CoverageThresholdError)) {
|
|
71
|
+
console.error(error?.stack ?? String(error))
|
|
72
|
+
}
|
|
73
|
+
process.exit(1)
|
|
74
|
+
})
|
|
69
75
|
|
|
70
76
|
async function runVitest(coverage, combinedConfig) {
|
|
71
77
|
const vitestArgs = ["run", "--passWithNoTests"]
|
|
@@ -298,6 +304,7 @@ async function convertNodeCoverage(coverage) {
|
|
|
298
304
|
|
|
299
305
|
const entries = await fsPromises.readdir(nodeCoverageDir).catch(() => [])
|
|
300
306
|
const scripts = []
|
|
307
|
+
const scriptRoots = resolveCollectCoverageRoots(config.collectCoverageFrom, config.rootDir)
|
|
301
308
|
|
|
302
309
|
for (const entry of entries) {
|
|
303
310
|
if (!entry.endsWith(".json")) {
|
|
@@ -309,12 +316,16 @@ async function convertNodeCoverage(coverage) {
|
|
|
309
316
|
const results = Array.isArray(payload?.result) ? payload.result : []
|
|
310
317
|
|
|
311
318
|
for (const script of results) {
|
|
312
|
-
const normalized = normalizeNodeScriptUrl(script.url, config.
|
|
319
|
+
const normalized = normalizeNodeScriptUrl(script.url, config.rootDir)
|
|
313
320
|
if (!normalized) {
|
|
314
321
|
continue
|
|
315
322
|
}
|
|
316
323
|
|
|
317
|
-
if (
|
|
324
|
+
if (isNodeModulesPath(normalized.absolutePath)) {
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!isInsideAnyRoot(normalized.absolutePath, scriptRoots)) {
|
|
318
329
|
continue
|
|
319
330
|
}
|
|
320
331
|
|
|
@@ -360,7 +371,7 @@ async function readJson(filePath) {
|
|
|
360
371
|
}
|
|
361
372
|
}
|
|
362
373
|
|
|
363
|
-
function normalizeNodeScriptUrl(rawUrl,
|
|
374
|
+
function normalizeNodeScriptUrl(rawUrl, rootDir) {
|
|
364
375
|
if (!rawUrl || rawUrl.startsWith("node:")) {
|
|
365
376
|
return null
|
|
366
377
|
}
|
|
@@ -384,21 +395,18 @@ function normalizeNodeScriptUrl(rawUrl, workspaceRoot) {
|
|
|
384
395
|
}
|
|
385
396
|
|
|
386
397
|
const normalized = path.normalize(absolutePath)
|
|
387
|
-
if (!normalized.startsWith(workspaceRoot)) {
|
|
388
|
-
return null
|
|
389
|
-
}
|
|
390
398
|
|
|
391
399
|
return {
|
|
392
400
|
absolutePath: normalized,
|
|
393
|
-
relativePath: path.relative(
|
|
401
|
+
relativePath: path.relative(rootDir, normalized),
|
|
394
402
|
}
|
|
395
403
|
}
|
|
396
404
|
|
|
397
|
-
function
|
|
398
|
-
return
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
405
|
+
function isNodeModulesPath(filePath) {
|
|
406
|
+
return path
|
|
407
|
+
.normalize(String(filePath ?? ""))
|
|
408
|
+
.split(path.sep)
|
|
409
|
+
.includes("node_modules")
|
|
402
410
|
}
|
|
403
411
|
|
|
404
412
|
function spawnWithLogs({ name, launcher, args, env, successMessage, failureMessage }) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
|
|
3
|
+
import picomatch from "picomatch"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export function createCollectCoverageMatcher(patterns, rootDir) {
|
|
7
|
+
const normalizedRoot = path.resolve(String(rootDir ?? ""))
|
|
8
|
+
|
|
9
|
+
const includeMatchers = []
|
|
10
|
+
const excludeMatchers = []
|
|
11
|
+
|
|
12
|
+
if (Array.isArray(patterns)) {
|
|
13
|
+
for (const pattern of patterns) {
|
|
14
|
+
const raw = String(pattern ?? "").trim()
|
|
15
|
+
if (!raw) {
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isExclude = raw.startsWith("!")
|
|
20
|
+
const body = toPosix(isExclude ? raw.slice(1) : raw)
|
|
21
|
+
if (!body) {
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const matcher = picomatch(body, { dot: true })
|
|
26
|
+
if (isExclude) {
|
|
27
|
+
excludeMatchers.push(matcher)
|
|
28
|
+
} else {
|
|
29
|
+
includeMatchers.push(matcher)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (includeMatchers.length === 0) {
|
|
35
|
+
return () => false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (absolutePath) => {
|
|
39
|
+
const normalizedAbsolute = path.resolve(String(absolutePath ?? ""))
|
|
40
|
+
if (!normalizedAbsolute) {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const relativePosix = toPosix(path.relative(normalizedRoot, normalizedAbsolute))
|
|
45
|
+
const absolutePosix = toPosix(normalizedAbsolute)
|
|
46
|
+
const candidates = new Set([absolutePosix, relativePosix])
|
|
47
|
+
|
|
48
|
+
if (relativePosix) {
|
|
49
|
+
candidates.add(`./${relativePosix}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const candidateList = Array.from(candidates)
|
|
53
|
+
const included = includeMatchers.some((matcher) => candidateList.some((candidate) => matcher(candidate)))
|
|
54
|
+
if (!included) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return !excludeMatchers.some((matcher) => candidateList.some((candidate) => matcher(candidate)))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function toPosix(input) {
|
|
63
|
+
return String(input ?? "").split(path.sep).join("/")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isInsideAnyRoot(absolutePath, roots) {
|
|
67
|
+
const normalizedAbsolute = path.resolve(String(absolutePath ?? ""))
|
|
68
|
+
return (Array.isArray(roots) ? roots : []).some((root) => {
|
|
69
|
+
const normalizedRoot = path.resolve(String(root ?? ""))
|
|
70
|
+
const relative = path.relative(normalizedRoot, normalizedAbsolute)
|
|
71
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveCollectCoverageRoots(patterns, rootDir) {
|
|
76
|
+
const resolvedRootDir = path.resolve(String(rootDir ?? ""))
|
|
77
|
+
const roots = new Set([resolvedRootDir])
|
|
78
|
+
|
|
79
|
+
if (!Array.isArray(patterns)) {
|
|
80
|
+
return Array.from(roots)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const pattern of patterns) {
|
|
84
|
+
const raw = String(pattern ?? "").trim()
|
|
85
|
+
if (!raw) {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cleaned = raw.startsWith("!") ? raw.slice(1) : raw
|
|
90
|
+
if (!cleaned) {
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const prefix = staticGlobPrefix(cleaned)
|
|
95
|
+
if (!prefix) {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
roots.add(path.resolve(resolvedRootDir, prefix))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Array.from(roots)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function staticGlobPrefix(pattern) {
|
|
106
|
+
const normalized = String(pattern ?? "").trim()
|
|
107
|
+
if (!normalized) {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const firstGlobIndex = findFirstGlobIndex(normalized)
|
|
112
|
+
const prefix = firstGlobIndex === -1 ? normalized : normalized.slice(0, firstGlobIndex)
|
|
113
|
+
|
|
114
|
+
if (!prefix) {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (prefix.endsWith("/") || prefix.endsWith(path.sep)) {
|
|
119
|
+
return prefix
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const posix = prefix.split(path.sep).join("/")
|
|
123
|
+
return path.posix.dirname(posix)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function findFirstGlobIndex(value) {
|
|
127
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
128
|
+
const char = value[i]
|
|
129
|
+
if (char === "*" || char === "?" || char === "[" || char === "{") {
|
|
130
|
+
return i
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return -1
|
|
134
|
+
}
|
|
@@ -8,6 +8,16 @@ import { build } from "esbuild"
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
const DEFAULT_COVERAGE_CANDIDATES = [
|
|
11
|
+
"spec/coverage.config.ts",
|
|
12
|
+
"spec/coverage.config.mts",
|
|
13
|
+
"spec/coverage.config.cts",
|
|
14
|
+
"spec/coverage.config.js",
|
|
15
|
+
"spec/coverage.config.mjs",
|
|
16
|
+
"spec/coverage.config.cjs",
|
|
17
|
+
"spec/coverage.config.json",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const LEGACY_COVERAGE_CANDIDATES = [
|
|
11
21
|
"spec/coverage.ts",
|
|
12
22
|
"spec/coverage.mts",
|
|
13
23
|
"spec/coverage.cts",
|
|
@@ -19,6 +29,15 @@ const DEFAULT_COVERAGE_CANDIDATES = [
|
|
|
19
29
|
|
|
20
30
|
export async function loadCoverageOptions({ optional = false, candidates = DEFAULT_COVERAGE_CANDIDATES, defaultTestResultsDir } = {}) {
|
|
21
31
|
const projectRoot = process.cwd()
|
|
32
|
+
const legacy = await findCoverageFile(projectRoot, LEGACY_COVERAGE_CANDIDATES)
|
|
33
|
+
if (legacy) {
|
|
34
|
+
const ext = path.extname(legacy)
|
|
35
|
+
const suggested = path.join("spec", `coverage.config${ext}`)
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Legacy coverage config detected (${path.relative(projectRoot, legacy)}). Rename it to ${suggested}.`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
const resolved = await findCoverageFile(projectRoot, candidates)
|
|
23
42
|
|
|
24
43
|
if (!resolved) {
|
|
@@ -26,7 +45,7 @@ export async function loadCoverageOptions({ optional = false, candidates = DEFAU
|
|
|
26
45
|
return null
|
|
27
46
|
}
|
|
28
47
|
throw new Error(
|
|
29
|
-
"Coverage config not found. Create
|
|
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
|
})
|
package/src/coverage/report.js
CHANGED
|
@@ -3,11 +3,14 @@ import path from "node:path"
|
|
|
3
3
|
import { fileURLToPath } from "node:url"
|
|
4
4
|
|
|
5
5
|
import * as libCoverage from "istanbul-lib-coverage"
|
|
6
|
+
import * as libInstrument from "istanbul-lib-instrument"
|
|
6
7
|
import { createContext } from "istanbul-lib-report"
|
|
7
8
|
import reports from "istanbul-reports"
|
|
9
|
+
import fg from "fast-glob"
|
|
8
10
|
import picomatch from "picomatch"
|
|
9
11
|
import v8ToIstanbul from "v8-to-istanbul"
|
|
10
12
|
|
|
13
|
+
import { createCollectCoverageMatcher, toPosix } from "./collect.js"
|
|
11
14
|
import { findCoverageFiles } from "./files.js"
|
|
12
15
|
|
|
13
16
|
|
|
@@ -30,6 +33,7 @@ export async function generateCoverageReport(config) {
|
|
|
30
33
|
|
|
31
34
|
const coverageLib = resolveCoverageLib()
|
|
32
35
|
const coverageMap = coverageLib.createCoverageMap({})
|
|
36
|
+
const matchesCollectCoverageFrom = createCollectCoverageMatcher(config.collectCoverageFrom, config.rootDir)
|
|
33
37
|
|
|
34
38
|
for (const file of coverageFiles) {
|
|
35
39
|
const payload = await readCoverageFile(file)
|
|
@@ -38,10 +42,14 @@ export async function generateCoverageReport(config) {
|
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
for (const script of payload.scripts) {
|
|
41
|
-
await mergeScriptCoverage(coverageMap, script, config)
|
|
45
|
+
await mergeScriptCoverage(coverageMap, script, config, matchesCollectCoverageFrom)
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
if (config.includeAllFiles) {
|
|
50
|
+
await includeUntestedFiles(coverageMap, config, matchesCollectCoverageFrom)
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
if (coverageMap.files().length === 0) {
|
|
46
54
|
console.warn("[coverage] no library files matched the coverage filters")
|
|
47
55
|
return
|
|
@@ -66,7 +74,7 @@ export async function generateCoverageReport(config) {
|
|
|
66
74
|
|
|
67
75
|
const targets = Array.isArray(config.thresholdTargets) ? config.thresholdTargets : []
|
|
68
76
|
if (targets.length > 0) {
|
|
69
|
-
const fileSummaries = buildFileSummaries(coverageMap, config.
|
|
77
|
+
const fileSummaries = buildFileSummaries(coverageMap, config.rootDir)
|
|
70
78
|
for (const target of targets) {
|
|
71
79
|
const matcher = createGlobMatcher(target.pattern)
|
|
72
80
|
const matchResult = collectTargetSummary(fileSummaries, matcher, coverageLib)
|
|
@@ -83,7 +91,7 @@ export async function generateCoverageReport(config) {
|
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
async function mergeScriptCoverage(coverageMap, script, config) {
|
|
94
|
+
async function mergeScriptCoverage(coverageMap, script, config, matchesCollectCoverageFrom) {
|
|
87
95
|
const scriptPath = script.absolutePath
|
|
88
96
|
if (!scriptPath) {
|
|
89
97
|
return
|
|
@@ -97,9 +105,21 @@ async function mergeScriptCoverage(coverageMap, script, config) {
|
|
|
97
105
|
return
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
|
|
108
|
+
let source = script.source && script.source.length > 0
|
|
101
109
|
? script.source
|
|
102
|
-
:
|
|
110
|
+
: ""
|
|
111
|
+
|
|
112
|
+
if (!source) {
|
|
113
|
+
try {
|
|
114
|
+
source = await fs.readFile(scriptPath, "utf8")
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const base = path.basename(scriptPath)
|
|
117
|
+
if (error?.code === "ENOENT" && base && !base.includes(".")) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
}
|
|
103
123
|
|
|
104
124
|
const sourceMap = await loadSourceMapForScript(scriptPath, source)
|
|
105
125
|
const converter = v8ToIstanbul(
|
|
@@ -110,7 +130,7 @@ async function mergeScriptCoverage(coverageMap, script, config) {
|
|
|
110
130
|
await converter.load()
|
|
111
131
|
converter.applyCoverage(script.functions)
|
|
112
132
|
|
|
113
|
-
const filtered = filterCoverageMap(converter.toIstanbul(), config)
|
|
133
|
+
const filtered = filterCoverageMap(converter.toIstanbul(), config, matchesCollectCoverageFrom)
|
|
114
134
|
if (Object.keys(filtered).length > 0) {
|
|
115
135
|
coverageMap.merge(filtered)
|
|
116
136
|
}
|
|
@@ -161,19 +181,156 @@ function resolveCoverageLib() {
|
|
|
161
181
|
throw new Error("istanbul-lib-coverage exports are unavailable")
|
|
162
182
|
}
|
|
163
183
|
|
|
164
|
-
function
|
|
165
|
-
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)
|
|
166
322
|
return coverageMap.files().map((filePath) => {
|
|
167
323
|
const normalizedAbsolute = path.resolve(filePath)
|
|
168
324
|
const summary = coverageMap.fileCoverageFor(filePath).toSummary()
|
|
169
325
|
const relativePath = path.relative(normalizedRoot, normalizedAbsolute)
|
|
170
|
-
const candidates = new Set(
|
|
326
|
+
const candidates = new Set()
|
|
171
327
|
|
|
172
|
-
if (relativePath
|
|
328
|
+
if (relativePath) {
|
|
173
329
|
const relativePosix = toPosix(relativePath)
|
|
174
330
|
candidates.add(relativePosix)
|
|
175
331
|
candidates.add(`./${relativePosix}`)
|
|
176
|
-
|
|
332
|
+
} else {
|
|
333
|
+
candidates.add(toPosix(path.basename(normalizedAbsolute)))
|
|
177
334
|
}
|
|
178
335
|
|
|
179
336
|
return {
|
|
@@ -202,11 +359,27 @@ function createGlobMatcher(pattern) {
|
|
|
202
359
|
if (!normalized) {
|
|
203
360
|
return () => false
|
|
204
361
|
}
|
|
362
|
+
if (isAbsoluteGlobPattern(normalized)) {
|
|
363
|
+
throw new Error(`[coverage] threshold patterns must be relative (absolute paths are not supported): "${pattern}"`)
|
|
364
|
+
}
|
|
205
365
|
return picomatch(normalized, { dot: true })
|
|
206
366
|
}
|
|
207
367
|
|
|
208
|
-
function
|
|
209
|
-
|
|
368
|
+
function isAbsoluteGlobPattern(pattern) {
|
|
369
|
+
const normalized = String(pattern ?? "").trim()
|
|
370
|
+
if (!normalized) {
|
|
371
|
+
return false
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (normalized.startsWith("/")) {
|
|
375
|
+
return true
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (normalized.startsWith("file://")) {
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return /^[A-Za-z]:\//.test(normalized)
|
|
210
383
|
}
|
|
211
384
|
|
|
212
385
|
function stripQuery(url) {
|
|
@@ -252,7 +425,7 @@ function resolveSourceMapPath(scriptPath, mappingUrl) {
|
|
|
252
425
|
return path.resolve(path.dirname(scriptPath), cleaned)
|
|
253
426
|
}
|
|
254
427
|
|
|
255
|
-
function filterCoverageMap(map, config) {
|
|
428
|
+
function filterCoverageMap(map, config, matchesCollectCoverageFrom) {
|
|
256
429
|
if (!map || typeof map !== "object") {
|
|
257
430
|
return {}
|
|
258
431
|
}
|
|
@@ -260,33 +433,38 @@ function filterCoverageMap(map, config) {
|
|
|
260
433
|
const filtered = {}
|
|
261
434
|
|
|
262
435
|
for (const [filePath, fileCoverage] of Object.entries(map)) {
|
|
263
|
-
|
|
436
|
+
const absolutePath = resolveCoveragePath(filePath, config.rootDir)
|
|
437
|
+
if (!absolutePath) {
|
|
264
438
|
continue
|
|
265
439
|
}
|
|
266
|
-
filtered[filePath] = fileCoverage
|
|
267
|
-
}
|
|
268
440
|
|
|
269
|
-
|
|
270
|
-
|
|
441
|
+
if (absolutePath.endsWith(".d.ts") || absolutePath.endsWith(".map")) {
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
271
444
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return false
|
|
276
|
-
}
|
|
445
|
+
if (isNodeModulesPath(absolutePath)) {
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
277
448
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
449
|
+
if (isViteVirtualModulePath(absolutePath)) {
|
|
450
|
+
continue
|
|
451
|
+
}
|
|
281
452
|
|
|
282
|
-
|
|
283
|
-
|
|
453
|
+
if (!matchesCollectCoverageFrom(absolutePath)) {
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (fileCoverage && typeof fileCoverage === "object") {
|
|
458
|
+
filtered[absolutePath] = { ...fileCoverage, path: absolutePath }
|
|
459
|
+
} else {
|
|
460
|
+
filtered[absolutePath] = fileCoverage
|
|
461
|
+
}
|
|
284
462
|
}
|
|
285
463
|
|
|
286
|
-
return
|
|
464
|
+
return filtered
|
|
287
465
|
}
|
|
288
466
|
|
|
289
|
-
function resolveCoveragePath(filePath,
|
|
467
|
+
function resolveCoveragePath(filePath, rootDir) {
|
|
290
468
|
const raw = String(filePath ?? "").trim()
|
|
291
469
|
if (!raw) {
|
|
292
470
|
return null
|
|
@@ -310,7 +488,7 @@ function resolveCoveragePath(filePath, workspaceRoot) {
|
|
|
310
488
|
return null
|
|
311
489
|
}
|
|
312
490
|
|
|
313
|
-
return path.normalize(path.resolve(
|
|
491
|
+
return path.normalize(path.resolve(rootDir, cleaned))
|
|
314
492
|
}
|
|
315
493
|
|
|
316
494
|
function isNodeModulesPath(filePath) {
|
|
@@ -328,13 +506,6 @@ function isViteVirtualModulePath(filePath) {
|
|
|
328
506
|
|| baseName.startsWith("__vite-")
|
|
329
507
|
}
|
|
330
508
|
|
|
331
|
-
function isInsideLib(absolutePath, libRoots) {
|
|
332
|
-
return libRoots.some((libRoot) => {
|
|
333
|
-
const relative = path.relative(libRoot, absolutePath)
|
|
334
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
335
|
-
})
|
|
336
|
-
}
|
|
337
|
-
|
|
338
509
|
function parseSourceMapPayload(raw) {
|
|
339
510
|
if (!raw || typeof raw !== "object") {
|
|
340
511
|
return null
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
+
import { isInsideAnyRoot, resolveCollectCoverageRoots } from "./collect.js"
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
const VITE_FS_PREFIX = "/@fs/"
|
|
6
8
|
|
|
@@ -9,10 +11,16 @@ function sanitizePathSegment(input) {
|
|
|
9
11
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "_") || "unknown"
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
function urlPathLooksLikeFile(pathname) {
|
|
15
|
+
const base = path.posix.basename(String(pathname ?? ""))
|
|
16
|
+
return base.length > 0 && base.includes(".")
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
export async function createCoverageTracker(page, config) {
|
|
13
20
|
const session = await page.context().newCDPSession(page)
|
|
14
21
|
const scriptMeta = new Map()
|
|
15
22
|
const sourceCache = new Map()
|
|
23
|
+
const scriptRoots = resolveCollectCoverageRoots(config.collectCoverageFrom, config.rootDir)
|
|
16
24
|
|
|
17
25
|
await session.send("Debugger.enable")
|
|
18
26
|
session.on("Debugger.scriptParsed", (event) => {
|
|
@@ -21,16 +29,14 @@ export async function createCoverageTracker(page, config) {
|
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
const normalized = normalizeScriptUrl(event.url, config)
|
|
24
|
-
const trackable = normalized
|
|
32
|
+
const trackable = normalized
|
|
33
|
+
&& !isNodeModulesPath(normalized.absolutePath)
|
|
34
|
+
&& isInsideAnyRoot(normalized.absolutePath, scriptRoots)
|
|
25
35
|
|
|
26
36
|
scriptMeta.set(event.scriptId, {
|
|
27
37
|
normalized: trackable ? normalized : null,
|
|
28
38
|
url: event.url,
|
|
29
39
|
})
|
|
30
|
-
|
|
31
|
-
if (trackable && !sourceCache.has(event.scriptId)) {
|
|
32
|
-
sourceCache.set(event.scriptId, fetchScriptSource(session, event.scriptId))
|
|
33
|
-
}
|
|
34
40
|
})
|
|
35
41
|
|
|
36
42
|
await session.send("Profiler.enable")
|
|
@@ -102,8 +108,23 @@ async function resolveScriptSource(session, cache, scriptId) {
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
async function fetchScriptSource(session, scriptId) {
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
try {
|
|
112
|
+
const sourceResponse = await session.send("Debugger.getScriptSource", { scriptId })
|
|
113
|
+
return sourceResponse?.scriptSource ?? ""
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = String(error?.message ?? error)
|
|
116
|
+
if (message.includes("Debugger agent is not enabled")) {
|
|
117
|
+
try {
|
|
118
|
+
await session.send("Debugger.enable")
|
|
119
|
+
const sourceResponse = await session.send("Debugger.getScriptSource", { scriptId })
|
|
120
|
+
return sourceResponse?.scriptSource ?? ""
|
|
121
|
+
} catch {
|
|
122
|
+
return ""
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return ""
|
|
127
|
+
}
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
async function shutdownSession(session) {
|
|
@@ -140,22 +161,22 @@ function normalizeScriptUrl(rawUrl, config) {
|
|
|
140
161
|
if (decoded.startsWith(VITE_FS_PREFIX)) {
|
|
141
162
|
absolutePath = path.normalize(decoded.slice(VITE_FS_PREFIX.length))
|
|
142
163
|
} else if (decoded.startsWith("/")) {
|
|
164
|
+
if (!urlPathLooksLikeFile(decoded)) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
143
167
|
absolutePath = path.resolve(process.cwd(), `.${decoded}`)
|
|
144
168
|
} else {
|
|
145
169
|
return null
|
|
146
170
|
}
|
|
147
171
|
|
|
148
|
-
return createNormalizedPath(absolutePath, config.
|
|
172
|
+
return createNormalizedPath(absolutePath, config.rootDir)
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
function createNormalizedPath(absolutePath,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const relativePath = path.relative(workspaceRoot, absolutePath)
|
|
175
|
+
function createNormalizedPath(absolutePath, rootDir) {
|
|
176
|
+
const normalizedAbsolute = path.normalize(absolutePath)
|
|
177
|
+
const relativePath = path.relative(rootDir, normalizedAbsolute)
|
|
157
178
|
return {
|
|
158
|
-
absolutePath,
|
|
179
|
+
absolutePath: normalizedAbsolute,
|
|
159
180
|
relativePath,
|
|
160
181
|
}
|
|
161
182
|
}
|
|
@@ -176,9 +197,9 @@ function stripQuery(url) {
|
|
|
176
197
|
return url.slice(0, endIndex)
|
|
177
198
|
}
|
|
178
199
|
|
|
179
|
-
function
|
|
180
|
-
return
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
function isNodeModulesPath(filePath) {
|
|
201
|
+
return path
|
|
202
|
+
.normalize(String(filePath ?? ""))
|
|
203
|
+
.split(path.sep)
|
|
204
|
+
.includes("node_modules")
|
|
184
205
|
}
|