probatio 0.1.0 → 0.1.3
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 +50 -14
- package/dist/cli.js +101 -53
- package/dist/contract.js +7 -5
- package/dist/mcp.js +23 -0
- package/dist/mutate/child-lines.mjs +34 -0
- package/dist/mutate/coverage-map.js +75 -18
- package/dist/mutate/generate.js +25 -4
- package/dist/mutate/mocha-coverage.cjs +71 -0
- package/dist/mutate/node-batch-loader.mjs +85 -0
- package/dist/mutate/node-batch.mjs +130 -0
- package/dist/mutate/node-coverage.mjs +37 -180
- package/dist/mutate/precise-lines.mjs +188 -0
- package/dist/mutate/run.js +319 -47
- package/dist/mutate/rust-coverage.js +4 -19
- package/dist/mutate/sealed.js +40 -1
- package/dist/mutate/suite-decision.js +1 -1
- package/dist/mutate/suites.js +88 -12
- package/dist/mutate/tally.js +38 -1
- package/dist/node-version.js +13 -0
- package/dist/swarm/queue.js +13 -7
- package/dist/verify/change.js +71 -2
- package/package.json +6 -4
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Cache-busts package files so the next mutant in this process loads the patched source.
|
|
2
|
+
// The generation lives in a file because the loader thread does not see later env edits.
|
|
3
|
+
import { createRequire } from "node:module"
|
|
4
|
+
import { readFileSync, realpathSync } from "node:fs"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
const ts = require("typescript")
|
|
10
|
+
|
|
11
|
+
function generation() {
|
|
12
|
+
const file = process.env.PB_GEN
|
|
13
|
+
if (!file) return ""
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync(file, "utf8").trim()
|
|
16
|
+
} catch {
|
|
17
|
+
return ""
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function packageRoot() {
|
|
22
|
+
const root = process.env.PB_ROOT
|
|
23
|
+
if (!root) return ""
|
|
24
|
+
try {
|
|
25
|
+
return realpathSync(root)
|
|
26
|
+
} catch {
|
|
27
|
+
return root
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function underPackage(abs) {
|
|
32
|
+
const root = packageRoot()
|
|
33
|
+
if (!root) return false
|
|
34
|
+
let real = abs
|
|
35
|
+
try {
|
|
36
|
+
real = realpathSync(abs)
|
|
37
|
+
} catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
44
|
+
const resolved = await nextResolve(specifier, context)
|
|
45
|
+
const gen = generation()
|
|
46
|
+
if (!gen) return resolved
|
|
47
|
+
let abs
|
|
48
|
+
try {
|
|
49
|
+
abs = fileURLToPath(resolved.url.split("?")[0])
|
|
50
|
+
} catch {
|
|
51
|
+
return resolved
|
|
52
|
+
}
|
|
53
|
+
if (!underPackage(abs)) return resolved
|
|
54
|
+
if (abs.split(path.sep).includes("node_modules")) return resolved
|
|
55
|
+
const url = new URL(resolved.url.split("?")[0])
|
|
56
|
+
url.searchParams.set("pb", gen)
|
|
57
|
+
return { url: url.href, shortCircuit: true }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function load(url, context, nextLoad) {
|
|
61
|
+
if (!url.includes("pb=")) return nextLoad(url, context)
|
|
62
|
+
const clean = url.split("?")[0]
|
|
63
|
+
let abs
|
|
64
|
+
try {
|
|
65
|
+
abs = fileURLToPath(clean)
|
|
66
|
+
} catch {
|
|
67
|
+
return nextLoad(url, context)
|
|
68
|
+
}
|
|
69
|
+
const source = readFileSync(abs, "utf8")
|
|
70
|
+
if (abs.endsWith(".ts") || abs.endsWith(".tsx") || abs.endsWith(".mts")) {
|
|
71
|
+
const transpiled = ts.transpileModule(source, {
|
|
72
|
+
fileName: abs,
|
|
73
|
+
compilerOptions: {
|
|
74
|
+
module: ts.ModuleKind.ESNext,
|
|
75
|
+
target: ts.ScriptTarget.ES2022,
|
|
76
|
+
sourceMap: false,
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
return { format: "module", source: transpiled.outputText, shortCircuit: true }
|
|
80
|
+
}
|
|
81
|
+
if (abs.endsWith(".mjs") || abs.endsWith(".js") || source.includes("import ") || source.includes("export ")) {
|
|
82
|
+
return { format: "module", source, shortCircuit: true }
|
|
83
|
+
}
|
|
84
|
+
return { format: "commonjs", source, shortCircuit: true }
|
|
85
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// One suite process for covered mutants of one file. Jobs arrive on stdin.
|
|
2
|
+
// A mutant that exits this process ends it. The parent starts a clean process for the next mutant.
|
|
3
|
+
import { createInterface } from "node:readline"
|
|
4
|
+
import { realpathSync, writeFileSync } from "node:fs"
|
|
5
|
+
import { register } from "node:module"
|
|
6
|
+
import { run } from "node:test"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import { openLineSampler } from "./precise-lines.mjs"
|
|
9
|
+
|
|
10
|
+
const loader = new URL("./node-batch-loader.mjs", import.meta.url).href
|
|
11
|
+
register(loader, { parentURL: import.meta.url })
|
|
12
|
+
|
|
13
|
+
let sampler = null
|
|
14
|
+
try {
|
|
15
|
+
sampler = await openLineSampler()
|
|
16
|
+
} catch {
|
|
17
|
+
sampler = null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
writeFileSync(process.env.PB_READY, `${JSON.stringify({ pid: process.pid })}\n`)
|
|
21
|
+
|
|
22
|
+
function escapeRegExp(value) {
|
|
23
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let generation = 0
|
|
27
|
+
|
|
28
|
+
async function runJob(job) {
|
|
29
|
+
generation += 1
|
|
30
|
+
writeFileSync(process.env.PB_GEN, String(generation))
|
|
31
|
+
if (sampler) await sampler.reset()
|
|
32
|
+
const files = (job.tests || []).map((rel) => path.join(job.root, rel))
|
|
33
|
+
const patterns = (job.names || []).filter((name) => name.length > 0).map((name) => new RegExp(`^${escapeRegExp(name)}$`))
|
|
34
|
+
const failed = []
|
|
35
|
+
const names = []
|
|
36
|
+
const testTimeout = job.testTimeoutMs > 0 ? job.testTimeoutMs : job.timeoutMs
|
|
37
|
+
const stream = run({
|
|
38
|
+
files,
|
|
39
|
+
isolation: "none",
|
|
40
|
+
timeout: testTimeout,
|
|
41
|
+
...(patterns.length > 0 ? { testNamePatterns: patterns } : {}),
|
|
42
|
+
})
|
|
43
|
+
let lastEvent = Date.now()
|
|
44
|
+
const started = Date.now()
|
|
45
|
+
let ended = false
|
|
46
|
+
let timer = null
|
|
47
|
+
const collected = (async () => {
|
|
48
|
+
for await (const event of stream) {
|
|
49
|
+
lastEvent = Date.now()
|
|
50
|
+
if (event.type !== "test:pass" && event.type !== "test:fail") continue
|
|
51
|
+
const data = event.data || {}
|
|
52
|
+
const name = typeof data.name === "string" ? data.name : ""
|
|
53
|
+
if (!name || files.includes(name)) continue
|
|
54
|
+
if (!names.includes(name)) names.push(name)
|
|
55
|
+
if (event.type === "test:fail") {
|
|
56
|
+
const error = data.details && data.details.error
|
|
57
|
+
const text = error ? `${error.failureType || ""} ${error.message || ""} ${error.cause || ""}` : ""
|
|
58
|
+
const fileTimeout = /testTimeoutFailure|timed out/i.test(text) && fileNameIs(name, data.file || "")
|
|
59
|
+
failed.push({ name, file: data.file || "", line: data.line || 0, ...(fileTimeout ? { fileTimeout: true } : {}) })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
ended = true
|
|
63
|
+
})()
|
|
64
|
+
let dirty = false
|
|
65
|
+
try {
|
|
66
|
+
dirty = await Promise.race([
|
|
67
|
+
collected.then(() => false),
|
|
68
|
+
new Promise((resolve) => {
|
|
69
|
+
timer = setInterval(() => {
|
|
70
|
+
if (ended) {
|
|
71
|
+
resolve(false)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const idle = Date.now() - lastEvent
|
|
75
|
+
const overdue = Date.now() - started >= testTimeout && idle >= 400 && names.length > 0
|
|
76
|
+
if (!overdue) return
|
|
77
|
+
resolve(true)
|
|
78
|
+
}, 50)
|
|
79
|
+
timer.unref()
|
|
80
|
+
}),
|
|
81
|
+
])
|
|
82
|
+
} finally {
|
|
83
|
+
if (timer) clearInterval(timer)
|
|
84
|
+
}
|
|
85
|
+
if (dirty && failed.length === 0) {
|
|
86
|
+
for (const rel of job.tests || []) {
|
|
87
|
+
const posix = rel.split(path.sep).join("/")
|
|
88
|
+
let file = path.join(job.root, rel)
|
|
89
|
+
try {
|
|
90
|
+
file = realpathSync(file)
|
|
91
|
+
} catch {
|
|
92
|
+
// The reporter uses the path it opened. A missing file keeps the joined path.
|
|
93
|
+
}
|
|
94
|
+
failed.push({ name: posix, file, line: 1, fileTimeout: true })
|
|
95
|
+
if (!names.includes(posix)) names.push(posix)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (sampler && !dirty) await sampler.reset()
|
|
99
|
+
const pattern = (job.names || []).join("|")
|
|
100
|
+
const command = ["node", "--test", ...(pattern ? [`--test-name-pattern=${pattern}`] : []), ...(job.tests || [])].join(" ")
|
|
101
|
+
return { id: job.id, pid: process.pid, failed, names, command, dirty }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fileNameIs(name, file) {
|
|
105
|
+
const base = String(file).split(/[/\\]/).pop()
|
|
106
|
+
if (!base) return false
|
|
107
|
+
return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lines = createInterface({ input: process.stdin })
|
|
111
|
+
for await (const line of lines) {
|
|
112
|
+
if (!line.trim()) continue
|
|
113
|
+
const job = JSON.parse(line)
|
|
114
|
+
// node:test with isolation "none" waits until the event loop is idle.
|
|
115
|
+
// An open readline on stdin is that handle, so the job would never finish.
|
|
116
|
+
lines.pause()
|
|
117
|
+
process.stdin.unref()
|
|
118
|
+
try {
|
|
119
|
+
const reply = await runJob(job)
|
|
120
|
+
writeFileSync(job.replyFile, `${JSON.stringify(reply)}\n`)
|
|
121
|
+
// An open handle after the tests finished is the same file timeout node --test reports.
|
|
122
|
+
// The process is dirty, so it ends and the next mutant starts clean.
|
|
123
|
+
if (reply.dirty) process.exit(0)
|
|
124
|
+
} catch (err) {
|
|
125
|
+
writeFileSync(job.replyFile, `${JSON.stringify({ id: job.id, pid: process.pid, failed: [], names: [], error: err instanceof Error ? err.message : String(err) })}\n`)
|
|
126
|
+
} finally {
|
|
127
|
+
if (!process.stdin.destroyed) process.stdin.ref()
|
|
128
|
+
lines.resume()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,200 +1,29 @@
|
|
|
1
1
|
// Baseline-only per-test line map for node --test. Loaded with --import in each
|
|
2
2
|
// test-file process. Writes one shard; the runner merges shards after the suite.
|
|
3
3
|
// takePreciseCoverage resets counters, so each sample is only what ran since the last take.
|
|
4
|
+
// A plain-script child does not run these hooks. It may leave a nameless hit list in
|
|
5
|
+
// ${map}.children. afterEach stores that list under the parent test before the shard is written.
|
|
4
6
|
import { randomBytes } from "node:crypto"
|
|
5
|
-
import { mkdirSync, writeFileSync } from "node:fs"
|
|
6
|
-
import { Session } from "node:inspector/promises"
|
|
7
|
-
import { findSourceMap } from "node:module"
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
8
8
|
import { after, afterEach, before } from "node:test"
|
|
9
9
|
import path from "node:path"
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
const B64 = new Int32Array(128)
|
|
13
|
-
for (let i = 0; i < 64; i++) B64["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(i)] = i
|
|
14
|
-
|
|
15
|
-
function decodeSegments(row) {
|
|
16
|
-
const segments = []
|
|
17
|
-
let i = 0
|
|
18
|
-
while (i < row.length) {
|
|
19
|
-
const fields = []
|
|
20
|
-
while (i < row.length && row.charCodeAt(i) !== 44) {
|
|
21
|
-
let value = 0
|
|
22
|
-
let shift = 0
|
|
23
|
-
let digit = 0
|
|
24
|
-
do {
|
|
25
|
-
digit = B64[row.charCodeAt(i++)]
|
|
26
|
-
value |= (digit & 31) << shift
|
|
27
|
-
shift += 5
|
|
28
|
-
} while (digit & 32)
|
|
29
|
-
fields.push(value & 1 ? -(value >> 1) : value >> 1)
|
|
30
|
-
}
|
|
31
|
-
if (row.charCodeAt(i) === 44) i++
|
|
32
|
-
if (fields.length > 0) segments.push(fields)
|
|
33
|
-
}
|
|
34
|
-
return segments
|
|
35
|
-
}
|
|
10
|
+
import { openLineSampler } from "./precise-lines.mjs"
|
|
36
11
|
|
|
37
12
|
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
38
13
|
if (mapPath) {
|
|
39
|
-
|
|
40
|
-
let started = false
|
|
14
|
+
let sampler = null
|
|
41
15
|
try {
|
|
42
|
-
|
|
43
|
-
await session.post("Profiler.enable")
|
|
44
|
-
await session.post("Debugger.enable")
|
|
45
|
-
await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
|
|
46
|
-
started = true
|
|
16
|
+
sampler = await openLineSampler((rel) => rel.endsWith("node-coverage.mjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("child-lines.mjs"))
|
|
47
17
|
} catch (err) {
|
|
48
18
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
49
19
|
}
|
|
50
20
|
|
|
51
|
-
if (
|
|
52
|
-
const textCache = new Map()
|
|
53
|
-
const indexCache = new Map()
|
|
21
|
+
if (sampler) {
|
|
54
22
|
/** @type {Map<string, Map<number, Set<string>>>} */
|
|
55
23
|
const byFile = new Map()
|
|
56
24
|
/** @type {Array<{ file: string, line: number }>} */
|
|
57
25
|
let prelude = []
|
|
58
26
|
|
|
59
|
-
async function scriptText(script) {
|
|
60
|
-
if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
|
|
61
|
-
let text = ""
|
|
62
|
-
try {
|
|
63
|
-
const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
|
|
64
|
-
text = got.scriptSource || ""
|
|
65
|
-
} catch {
|
|
66
|
-
text = ""
|
|
67
|
-
}
|
|
68
|
-
textCache.set(script.scriptId, text)
|
|
69
|
-
return text
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function relPath(url) {
|
|
73
|
-
if (!url || url.startsWith("node:")) return null
|
|
74
|
-
let abs
|
|
75
|
-
try {
|
|
76
|
-
abs = fileURLToPath(url.split("?")[0].split("#")[0])
|
|
77
|
-
} catch {
|
|
78
|
-
return null
|
|
79
|
-
}
|
|
80
|
-
const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
|
|
81
|
-
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
|
|
82
|
-
if (rel.split("/").includes("node_modules")) return null
|
|
83
|
-
if (rel.endsWith("node-coverage.mjs")) return null
|
|
84
|
-
return rel
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function indexFor(scriptId, text, sm) {
|
|
88
|
-
if (indexCache.has(scriptId)) return indexCache.get(scriptId)
|
|
89
|
-
const mapped = sm && sm.payload && typeof sm.payload.mappings === "string" ? linesFromMappings(text, sm.payload.mappings) : null
|
|
90
|
-
const lines = mapped && hasLine(mapped) ? mapped : linesFromNewlines(text, sm)
|
|
91
|
-
indexCache.set(scriptId, lines)
|
|
92
|
-
return lines
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function hasLine(lines) {
|
|
96
|
-
for (let i = 0; i < lines.length; i++) if (lines[i] > 0) return true
|
|
97
|
-
return false
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function linesFromNewlines(text, sm) {
|
|
101
|
-
const lines = new Int32Array(text.length + 1)
|
|
102
|
-
if (!sm) {
|
|
103
|
-
let line = 1
|
|
104
|
-
for (let i = 0; i < text.length; i++) {
|
|
105
|
-
lines[i] = line
|
|
106
|
-
if (text.charCodeAt(i) === 10) line++
|
|
107
|
-
}
|
|
108
|
-
return lines
|
|
109
|
-
}
|
|
110
|
-
// Node 22 reports SourceMap.findEntry originalLine as 0-based.
|
|
111
|
-
let genLine = 0
|
|
112
|
-
let col = 0
|
|
113
|
-
for (let i = 0; i < text.length; i++) {
|
|
114
|
-
const entry = sm.findEntry(genLine, col)
|
|
115
|
-
if (entry && Number.isInteger(entry.originalLine) && entry.originalLine >= 0) lines[i] = entry.originalLine + 1
|
|
116
|
-
if (text.charCodeAt(i) === 10) {
|
|
117
|
-
genLine++
|
|
118
|
-
col = 0
|
|
119
|
-
} else {
|
|
120
|
-
col++
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return lines
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function linesFromMappings(text, mappings) {
|
|
127
|
-
const lines = new Int32Array(text.length + 1)
|
|
128
|
-
const starts = [0]
|
|
129
|
-
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
|
|
130
|
-
let carryLine = 0
|
|
131
|
-
const rows = mappings.split(";")
|
|
132
|
-
for (let genLine = 0; genLine < rows.length && genLine < starts.length; genLine++) {
|
|
133
|
-
const start = starts[genLine]
|
|
134
|
-
const next = genLine + 1 < starts.length ? starts[genLine + 1] : text.length
|
|
135
|
-
let col = 0
|
|
136
|
-
let cursor = start
|
|
137
|
-
let active = -1
|
|
138
|
-
for (const fields of decodeSegments(rows[genLine])) {
|
|
139
|
-
col += fields[0]
|
|
140
|
-
const at = Math.min(next, start + Math.max(0, col))
|
|
141
|
-
if (active >= 0) {
|
|
142
|
-
for (let i = cursor; i < at; i++) lines[i] = active + 1
|
|
143
|
-
}
|
|
144
|
-
cursor = at
|
|
145
|
-
if (fields.length >= 4) {
|
|
146
|
-
carryLine += fields[2]
|
|
147
|
-
active = carryLine
|
|
148
|
-
} else {
|
|
149
|
-
active = -1
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (active >= 0) {
|
|
153
|
-
for (let i = cursor; i < next; i++) lines[i] = active + 1
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return lines
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function applyRanges(text, script, lines) {
|
|
160
|
-
const ranges = []
|
|
161
|
-
for (const fn of script.functions || []) {
|
|
162
|
-
for (const range of fn.ranges || []) ranges.push(range)
|
|
163
|
-
}
|
|
164
|
-
// Outer ranges first. A nested block then overwrites its own offsets, so a dead
|
|
165
|
-
// branch stays at 0 while a condition earlier on that same line can stay covered.
|
|
166
|
-
ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset)
|
|
167
|
-
const offsets = new Int32Array(text.length)
|
|
168
|
-
for (const range of ranges) {
|
|
169
|
-
const end = Math.min(text.length, range.endOffset)
|
|
170
|
-
for (let i = Math.max(0, range.startOffset); i < end; i++) offsets[i] = range.count
|
|
171
|
-
}
|
|
172
|
-
const counts = new Map()
|
|
173
|
-
for (let i = 0; i < text.length; i++) {
|
|
174
|
-
const line = lines[i]
|
|
175
|
-
if (line <= 0 || offsets[i] <= 0) continue
|
|
176
|
-
if (offsets[i] > (counts.get(line) || 0)) counts.set(line, offsets[i])
|
|
177
|
-
}
|
|
178
|
-
return counts
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function positiveLines() {
|
|
182
|
-
const cov = await session.post("Profiler.takePreciseCoverage")
|
|
183
|
-
/** @type {Array<{ file: string, line: number }>} */
|
|
184
|
-
const found = []
|
|
185
|
-
for (const script of cov.result || []) {
|
|
186
|
-
const file = relPath(script.url)
|
|
187
|
-
if (!file) continue
|
|
188
|
-
const text = await scriptText(script)
|
|
189
|
-
if (!text) continue
|
|
190
|
-
const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
|
|
191
|
-
for (const [line, count] of counts) {
|
|
192
|
-
if (count > 0) found.push({ file, line })
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return found
|
|
196
|
-
}
|
|
197
|
-
|
|
198
27
|
function add(hits, name) {
|
|
199
28
|
if (!name) return
|
|
200
29
|
for (const hit of hits) {
|
|
@@ -212,9 +41,36 @@ if (mapPath) {
|
|
|
212
41
|
}
|
|
213
42
|
}
|
|
214
43
|
|
|
44
|
+
function takeChildHits() {
|
|
45
|
+
const dir = `${mapPath}.children`
|
|
46
|
+
if (!existsSync(dir)) return []
|
|
47
|
+
/** @type {Array<{ file: string, line: number }>} */
|
|
48
|
+
const hits = []
|
|
49
|
+
for (const name of readdirSync(dir)) {
|
|
50
|
+
if (!name.endsWith(".json")) continue
|
|
51
|
+
const full = path.join(dir, name)
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(full, "utf8"))
|
|
54
|
+
const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
|
|
55
|
+
for (const hit of list) {
|
|
56
|
+
if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
|
|
57
|
+
hits.push({ file: hit.file, line: hit.line })
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// A torn dump is ignored. The parent does not invent a line.
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
unlinkSync(full)
|
|
64
|
+
} catch {
|
|
65
|
+
// The next test must not inherit this dump.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return hits
|
|
69
|
+
}
|
|
70
|
+
|
|
215
71
|
before(async () => {
|
|
216
72
|
try {
|
|
217
|
-
prelude = await positiveLines()
|
|
73
|
+
prelude = await sampler.positiveLines()
|
|
218
74
|
} catch (err) {
|
|
219
75
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
220
76
|
}
|
|
@@ -223,7 +79,8 @@ if (mapPath) {
|
|
|
223
79
|
afterEach(async (t) => {
|
|
224
80
|
try {
|
|
225
81
|
add(prelude, t.name)
|
|
226
|
-
add(await positiveLines(), t.name)
|
|
82
|
+
add(await sampler.positiveLines(), t.name)
|
|
83
|
+
add(takeChildHits(), t.name)
|
|
227
84
|
} catch (err) {
|
|
228
85
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
229
86
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Inspector line samples shared by the node:test collector, the Mocha collector,
|
|
2
|
+
// and a plain-script child. This file has no test hooks and writes no shard.
|
|
3
|
+
import { Session } from "node:inspector/promises"
|
|
4
|
+
import { findSourceMap } from "node:module"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const B64 = new Int32Array(128)
|
|
9
|
+
for (let i = 0; i < 64; i++) B64["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(i)] = i
|
|
10
|
+
|
|
11
|
+
function decodeSegments(row) {
|
|
12
|
+
const segments = []
|
|
13
|
+
let i = 0
|
|
14
|
+
while (i < row.length) {
|
|
15
|
+
const fields = []
|
|
16
|
+
while (i < row.length && row.charCodeAt(i) !== 44) {
|
|
17
|
+
let value = 0
|
|
18
|
+
let shift = 0
|
|
19
|
+
let digit = 0
|
|
20
|
+
do {
|
|
21
|
+
digit = B64[row.charCodeAt(i++)]
|
|
22
|
+
value |= (digit & 31) << shift
|
|
23
|
+
shift += 5
|
|
24
|
+
} while (digit & 32)
|
|
25
|
+
fields.push(value & 1 ? -(value >> 1) : value >> 1)
|
|
26
|
+
}
|
|
27
|
+
if (row.charCodeAt(i) === 44) i++
|
|
28
|
+
if (fields.length > 0) segments.push(fields)
|
|
29
|
+
}
|
|
30
|
+
return segments
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {(rel: string) => boolean} [skipRel]
|
|
35
|
+
* @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function openLineSampler(skipRel) {
|
|
38
|
+
const session = new Session()
|
|
39
|
+
session.connect()
|
|
40
|
+
await session.post("Profiler.enable")
|
|
41
|
+
await session.post("Debugger.enable")
|
|
42
|
+
await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
|
|
43
|
+
|
|
44
|
+
const textCache = new Map()
|
|
45
|
+
const indexCache = new Map()
|
|
46
|
+
|
|
47
|
+
async function scriptText(script) {
|
|
48
|
+
if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
|
|
49
|
+
let text = ""
|
|
50
|
+
try {
|
|
51
|
+
const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
|
|
52
|
+
text = got.scriptSource || ""
|
|
53
|
+
} catch {
|
|
54
|
+
text = ""
|
|
55
|
+
}
|
|
56
|
+
textCache.set(script.scriptId, text)
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function relPath(url) {
|
|
61
|
+
if (!url || url.startsWith("node:")) return null
|
|
62
|
+
let abs
|
|
63
|
+
try {
|
|
64
|
+
abs = fileURLToPath(url.split("?")[0].split("#")[0])
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
|
|
69
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
|
|
70
|
+
if (rel.split("/").includes("node_modules")) return null
|
|
71
|
+
if (skipRel && skipRel(rel)) return null
|
|
72
|
+
return rel
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function indexFor(scriptId, text, sm) {
|
|
76
|
+
if (indexCache.has(scriptId)) return indexCache.get(scriptId)
|
|
77
|
+
const mapped = sm && sm.payload && typeof sm.payload.mappings === "string" ? linesFromMappings(text, sm.payload.mappings) : null
|
|
78
|
+
const lines = mapped && hasLine(mapped) ? mapped : linesFromNewlines(text, sm)
|
|
79
|
+
indexCache.set(scriptId, lines)
|
|
80
|
+
return lines
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasLine(lines) {
|
|
84
|
+
for (let i = 0; i < lines.length; i++) if (lines[i] > 0) return true
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function linesFromNewlines(text, sm) {
|
|
89
|
+
const lines = new Int32Array(text.length + 1)
|
|
90
|
+
if (!sm) {
|
|
91
|
+
let line = 1
|
|
92
|
+
for (let i = 0; i < text.length; i++) {
|
|
93
|
+
lines[i] = line
|
|
94
|
+
if (text.charCodeAt(i) === 10) line++
|
|
95
|
+
}
|
|
96
|
+
return lines
|
|
97
|
+
}
|
|
98
|
+
let genLine = 0
|
|
99
|
+
let col = 0
|
|
100
|
+
for (let i = 0; i < text.length; i++) {
|
|
101
|
+
const entry = sm.findEntry(genLine, col)
|
|
102
|
+
if (entry && Number.isInteger(entry.originalLine) && entry.originalLine >= 0) lines[i] = entry.originalLine + 1
|
|
103
|
+
if (text.charCodeAt(i) === 10) {
|
|
104
|
+
genLine++
|
|
105
|
+
col = 0
|
|
106
|
+
} else {
|
|
107
|
+
col++
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return lines
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function linesFromMappings(text, mappings) {
|
|
114
|
+
const lines = new Int32Array(text.length + 1)
|
|
115
|
+
const starts = [0]
|
|
116
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
|
|
117
|
+
let carryLine = 0
|
|
118
|
+
const rows = mappings.split(";")
|
|
119
|
+
for (let genLine = 0; genLine < rows.length && genLine < starts.length; genLine++) {
|
|
120
|
+
const start = starts[genLine]
|
|
121
|
+
const next = genLine + 1 < starts.length ? starts[genLine + 1] : text.length
|
|
122
|
+
let col = 0
|
|
123
|
+
let cursor = start
|
|
124
|
+
let active = -1
|
|
125
|
+
for (const fields of decodeSegments(rows[genLine])) {
|
|
126
|
+
col += fields[0]
|
|
127
|
+
const at = Math.min(next, start + Math.max(0, col))
|
|
128
|
+
if (active >= 0) {
|
|
129
|
+
for (let i = cursor; i < at; i++) lines[i] = active + 1
|
|
130
|
+
}
|
|
131
|
+
cursor = at
|
|
132
|
+
if (fields.length >= 4) {
|
|
133
|
+
carryLine += fields[2]
|
|
134
|
+
active = carryLine
|
|
135
|
+
} else {
|
|
136
|
+
active = -1
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (active >= 0) {
|
|
140
|
+
for (let i = cursor; i < next; i++) lines[i] = active + 1
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return lines
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyRanges(text, script, lines) {
|
|
147
|
+
const ranges = []
|
|
148
|
+
for (const fn of script.functions || []) {
|
|
149
|
+
for (const range of fn.ranges || []) ranges.push(range)
|
|
150
|
+
}
|
|
151
|
+
ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset)
|
|
152
|
+
const offsets = new Int32Array(text.length)
|
|
153
|
+
for (const range of ranges) {
|
|
154
|
+
const end = Math.min(text.length, range.endOffset)
|
|
155
|
+
for (let i = Math.max(0, range.startOffset); i < end; i++) offsets[i] = range.count
|
|
156
|
+
}
|
|
157
|
+
const counts = new Map()
|
|
158
|
+
for (let i = 0; i < text.length; i++) {
|
|
159
|
+
const line = lines[i]
|
|
160
|
+
if (line <= 0 || offsets[i] <= 0) continue
|
|
161
|
+
if (offsets[i] > (counts.get(line) || 0)) counts.set(line, offsets[i])
|
|
162
|
+
}
|
|
163
|
+
return counts
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function positiveLines() {
|
|
167
|
+
const cov = await session.post("Profiler.takePreciseCoverage")
|
|
168
|
+
const found = []
|
|
169
|
+
for (const script of cov.result || []) {
|
|
170
|
+
const file = relPath(script.url)
|
|
171
|
+
if (!file) continue
|
|
172
|
+
const text = await scriptText(script)
|
|
173
|
+
if (!text) continue
|
|
174
|
+
const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
|
|
175
|
+
for (const [line, count] of counts) {
|
|
176
|
+
if (count > 0) found.push({ file, line })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return found
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
positiveLines,
|
|
184
|
+
reset: async () => {
|
|
185
|
+
await session.post("Profiler.takePreciseCoverage")
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|