probatio 0.1.2 → 0.2.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 +109 -17
- package/dist/cli.js +122 -55
- package/dist/contract.js +7 -5
- package/dist/ledger/build.js +14 -0
- package/dist/mcp.js +98 -26
- package/dist/mutate/child-lines.mjs +23 -18
- package/dist/mutate/coverage-map.js +75 -18
- package/dist/mutate/fail-reporter.mjs +21 -0
- package/dist/mutate/generate.js +25 -4
- package/dist/mutate/load-failure.mjs +40 -0
- package/dist/mutate/mocha-coverage.cjs +7 -1
- package/dist/mutate/node-batch-loader.mjs +15 -1
- package/dist/mutate/node-batch.mjs +40 -3
- package/dist/mutate/node-coverage.mjs +7 -32
- package/dist/mutate/patch.js +2 -1
- package/dist/mutate/precise-lines.mjs +147 -31
- package/dist/mutate/probatio-jacoco-run.java +2 -2
- package/dist/mutate/project-config.js +58 -0
- package/dist/mutate/run.js +192 -32
- package/dist/mutate/rust-coverage.js +4 -19
- package/dist/mutate/sealed.js +94 -1
- package/dist/mutate/suite-decision.js +2 -1
- package/dist/mutate/suites.js +136 -163
- package/dist/mutate/tally.js +85 -20
- package/dist/node-version.js +13 -0
- package/dist/swarm/check-kill.js +14 -7
- package/dist/swarm/queue.js +13 -7
- package/dist/verify/change.js +5 -0
- package/package.json +6 -4
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Inspector line samples shared by the node:test collector, the Mocha collector,
|
|
2
2
|
// and a plain-script child. This file has no test hooks and writes no shard.
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
|
|
4
|
+
import { Session as SyncSession } from "node:inspector"
|
|
3
5
|
import { Session } from "node:inspector/promises"
|
|
4
6
|
import { findSourceMap } from "node:module"
|
|
5
7
|
import path from "node:path"
|
|
@@ -31,32 +33,14 @@ function decodeSegments(row) {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
36
|
+
* Offsets to source lines, shared by the async sampler (test processes) and the sync one (children).
|
|
37
|
+
* Paths are relative to `root`. node_modules and paths outside root are left out.
|
|
34
38
|
* @param {(rel: string) => boolean} [skipRel]
|
|
35
|
-
* @
|
|
39
|
+
* @param {string} [root]
|
|
36
40
|
*/
|
|
37
|
-
|
|
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()
|
|
41
|
+
function lineConverter(skipRel, root = process.cwd()) {
|
|
45
42
|
const indexCache = new Map()
|
|
46
43
|
|
|
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
44
|
function relPath(url) {
|
|
61
45
|
if (!url || url.startsWith("node:")) return null
|
|
62
46
|
let abs
|
|
@@ -65,7 +49,7 @@ export async function openLineSampler(skipRel) {
|
|
|
65
49
|
} catch {
|
|
66
50
|
return null
|
|
67
51
|
}
|
|
68
|
-
const rel = path.relative(
|
|
52
|
+
const rel = path.relative(root, abs).split(path.sep).join("/")
|
|
69
53
|
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
|
|
70
54
|
if (rel.split("/").includes("node_modules")) return null
|
|
71
55
|
if (skipRel && skipRel(rel)) return null
|
|
@@ -163,18 +147,55 @@ export async function openLineSampler(skipRel) {
|
|
|
163
147
|
return counts
|
|
164
148
|
}
|
|
165
149
|
|
|
150
|
+
/** Lines with a positive count in one script, or null when the script is not ours. */
|
|
151
|
+
function scriptLines(script, text) {
|
|
152
|
+
const file = relPath(script.url)
|
|
153
|
+
if (!file || !text) return null
|
|
154
|
+
const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
|
|
155
|
+
const found = []
|
|
156
|
+
for (const [line, count] of counts) {
|
|
157
|
+
if (count > 0) found.push({ file, line })
|
|
158
|
+
}
|
|
159
|
+
return found
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { relPath, scriptLines }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {(rel: string) => boolean} [skipRel]
|
|
167
|
+
* @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
|
|
168
|
+
*/
|
|
169
|
+
export async function openLineSampler(skipRel) {
|
|
170
|
+
const session = new Session()
|
|
171
|
+
session.connect()
|
|
172
|
+
await session.post("Profiler.enable")
|
|
173
|
+
await session.post("Debugger.enable")
|
|
174
|
+
await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
|
|
175
|
+
|
|
176
|
+
const textCache = new Map()
|
|
177
|
+
const convert = lineConverter(skipRel)
|
|
178
|
+
|
|
179
|
+
async function scriptText(script) {
|
|
180
|
+
if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
|
|
181
|
+
let text = ""
|
|
182
|
+
try {
|
|
183
|
+
const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
|
|
184
|
+
text = got.scriptSource || ""
|
|
185
|
+
} catch {
|
|
186
|
+
text = ""
|
|
187
|
+
}
|
|
188
|
+
textCache.set(script.scriptId, text)
|
|
189
|
+
return text
|
|
190
|
+
}
|
|
191
|
+
|
|
166
192
|
async function positiveLines() {
|
|
167
193
|
const cov = await session.post("Profiler.takePreciseCoverage")
|
|
168
194
|
const found = []
|
|
169
195
|
for (const script of cov.result || []) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
}
|
|
196
|
+
if (!convert.relPath(script.url)) continue
|
|
197
|
+
const lines = convert.scriptLines(script, await scriptText(script))
|
|
198
|
+
if (lines) found.push(...lines)
|
|
178
199
|
}
|
|
179
200
|
return found
|
|
180
201
|
}
|
|
@@ -186,3 +207,98 @@ export async function openLineSampler(skipRel) {
|
|
|
186
207
|
},
|
|
187
208
|
}
|
|
188
209
|
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The same sampler with synchronous calls, so an `exit` handler can read it.
|
|
213
|
+
* `exit` runs after process.exit(), where a promise never settles.
|
|
214
|
+
* @param {(rel: string) => boolean} [skipRel]
|
|
215
|
+
* @param {string} [root]
|
|
216
|
+
*/
|
|
217
|
+
export function openSyncLineSampler(skipRel, root) {
|
|
218
|
+
const session = new SyncSession()
|
|
219
|
+
session.connect()
|
|
220
|
+
const post = (method, params) => {
|
|
221
|
+
let result
|
|
222
|
+
let failure = null
|
|
223
|
+
let done = false
|
|
224
|
+
session.post(method, params, (err, value) => {
|
|
225
|
+
failure = err
|
|
226
|
+
result = value
|
|
227
|
+
done = true
|
|
228
|
+
})
|
|
229
|
+
if (!done) throw new Error(`inspector ${method} did not answer synchronously`)
|
|
230
|
+
if (failure) throw failure
|
|
231
|
+
return result
|
|
232
|
+
}
|
|
233
|
+
post("Profiler.enable")
|
|
234
|
+
post("Debugger.enable")
|
|
235
|
+
post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
|
|
236
|
+
const convert = lineConverter(skipRel, root)
|
|
237
|
+
return {
|
|
238
|
+
positiveLines() {
|
|
239
|
+
const cov = post("Profiler.takePreciseCoverage")
|
|
240
|
+
const found = []
|
|
241
|
+
for (const script of cov.result || []) {
|
|
242
|
+
if (!convert.relPath(script.url)) continue
|
|
243
|
+
let text = ""
|
|
244
|
+
try {
|
|
245
|
+
text = post("Debugger.getScriptSource", { scriptId: script.scriptId }).scriptSource || ""
|
|
246
|
+
} catch {
|
|
247
|
+
text = ""
|
|
248
|
+
}
|
|
249
|
+
const lines = convert.scriptLines(script, text)
|
|
250
|
+
if (lines) found.push(...lines)
|
|
251
|
+
}
|
|
252
|
+
return found
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Called inside a test process that collects a line map. Every node child it starts
|
|
259
|
+
* loads child-lines.mjs through NODE_OPTIONS, with no import in the child's own code.
|
|
260
|
+
* The marker tells child-lines that this process already has test hooks.
|
|
261
|
+
*/
|
|
262
|
+
export function exposeChildLines() {
|
|
263
|
+
globalThis.__probatioTestHooks = true
|
|
264
|
+
// One dump dir per test process. Test files can run in parallel, and a child's lines
|
|
265
|
+
// belong to the test in the process that started it, not to a test in another file.
|
|
266
|
+
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
267
|
+
if (mapPath) process.env.PROBATIO_CHILD_DIR = childDir(mapPath)
|
|
268
|
+
const childLines = new URL("./child-lines.mjs", import.meta.url).href
|
|
269
|
+
const flag = `--import=${childLines}`
|
|
270
|
+
const current = process.env.NODE_OPTIONS || ""
|
|
271
|
+
if (!current.includes(flag)) process.env.NODE_OPTIONS = current ? `${current} ${flag}` : flag
|
|
272
|
+
if (!process.env.PROBATIO_PACKAGE_ROOT) process.env.PROBATIO_PACKAGE_ROOT = process.cwd()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function childDir(mapPath) {
|
|
276
|
+
return path.join(`${mapPath}.children`, String(process.pid))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Line dumps the children of the current test left. Each one is read once. */
|
|
280
|
+
export function takeChildHits(mapPath) {
|
|
281
|
+
const dir = childDir(mapPath)
|
|
282
|
+
if (!existsSync(dir)) return []
|
|
283
|
+
const hits = []
|
|
284
|
+
for (const name of readdirSync(dir)) {
|
|
285
|
+
if (!name.endsWith(".json")) continue
|
|
286
|
+
const full = path.join(dir, name)
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(readFileSync(full, "utf8"))
|
|
289
|
+
const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
|
|
290
|
+
for (const hit of list) {
|
|
291
|
+
if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
|
|
292
|
+
hits.push({ file: hit.file, line: hit.line })
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// A torn dump is ignored. The parent does not invent a line.
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
unlinkSync(full)
|
|
299
|
+
} catch {
|
|
300
|
+
// The next test must not inherit this dump.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return hits
|
|
304
|
+
}
|
|
@@ -85,8 +85,8 @@ public class ProbatioJacocoRun {
|
|
|
85
85
|
System.err.println("java coverage: tests ran but JaCoCo recorded no classes");
|
|
86
86
|
System.exit(1);
|
|
87
87
|
}
|
|
88
|
-
//
|
|
89
|
-
// wait on those threads until the parent clock kills the dump.
|
|
88
|
+
// Integration tests can leave non-daemon threads running (jsoup leaves Netty loops).
|
|
89
|
+
// A normal return would wait on those threads until the parent clock kills the dump.
|
|
90
90
|
System.exit(0);
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* `.probatio.json` at the package root. How to run this project's suite, written by the
|
|
5
|
+
* project and visible in review. Probatio does not patch a project's files or environment
|
|
6
|
+
* to make one repository pass. A project that needs that says so here.
|
|
7
|
+
*/
|
|
8
|
+
export const CONFIG_FILE = ".probatio.json";
|
|
9
|
+
const EMPTY = { env: {}, testLine: null, passLine: null };
|
|
10
|
+
export function readProjectConfig(pkg) {
|
|
11
|
+
const file = path.join(pkg, CONFIG_FILE);
|
|
12
|
+
if (!existsSync(file))
|
|
13
|
+
return EMPTY;
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return { error: `${CONFIG_FILE} is not JSON (${error instanceof Error ? error.message : String(error)})` };
|
|
20
|
+
}
|
|
21
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
22
|
+
return { error: `${CONFIG_FILE} must be one JSON object` };
|
|
23
|
+
const body = raw;
|
|
24
|
+
const env = {};
|
|
25
|
+
if (body.env !== undefined) {
|
|
26
|
+
if (!body.env || typeof body.env !== "object" || Array.isArray(body.env))
|
|
27
|
+
return { error: `${CONFIG_FILE} env must be an object of strings` };
|
|
28
|
+
for (const [key, value] of Object.entries(body.env)) {
|
|
29
|
+
if (typeof value !== "string")
|
|
30
|
+
return { error: `${CONFIG_FILE} env.${key} must be a string` };
|
|
31
|
+
env[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const testLine = pattern(body.testLine, "testLine");
|
|
35
|
+
if (typeof testLine === "string")
|
|
36
|
+
return { error: testLine };
|
|
37
|
+
const passLine = pattern(body.passLine, "passLine");
|
|
38
|
+
if (typeof passLine === "string")
|
|
39
|
+
return { error: passLine };
|
|
40
|
+
return { env, testLine, passLine };
|
|
41
|
+
}
|
|
42
|
+
/** The config, or the empty one when the file is unreadable. The runner refuses that case earlier. */
|
|
43
|
+
export function projectConfig(pkg) {
|
|
44
|
+
const read = readProjectConfig(pkg);
|
|
45
|
+
return "error" in read ? EMPTY : read;
|
|
46
|
+
}
|
|
47
|
+
function pattern(value, name) {
|
|
48
|
+
if (value === undefined || value === null)
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof value !== "string" || value.length === 0)
|
|
51
|
+
return `${CONFIG_FILE} ${name} must be a non-empty string`;
|
|
52
|
+
try {
|
|
53
|
+
return new RegExp(value);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return `${CONFIG_FILE} ${name} is not a regular expression (${error instanceof Error ? error.message : String(error)})`;
|
|
57
|
+
}
|
|
58
|
+
}
|