probatio 0.0.5 → 0.1.2
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 +5 -5
- package/dist/cli.js +24 -0
- package/dist/mutate/child-lines.mjs +34 -0
- 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/probatio-jacoco-run.java +10 -1
- package/dist/mutate/run.js +239 -34
- package/dist/mutate/suite-decision.js +1 -1
- package/dist/mutate/suites.js +75 -7
- package/dist/mutate/tally.js +1 -1
- package/dist/verify/change.js +83 -10
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -252,11 +252,17 @@ public class ProbatioJacocoRun {
|
|
|
252
252
|
Class<?> selectorType = Class.forName("org.junit.platform.engine.DiscoverySelector");
|
|
253
253
|
Class<?> selectorsType = Class.forName("org.junit.platform.engine.discovery.DiscoverySelectors");
|
|
254
254
|
java.lang.reflect.Method select = selectMethodWithTypes(selectorsType);
|
|
255
|
+
boolean typed = select.getParameterCount() == 3;
|
|
255
256
|
Object selectors = Array.newInstance(selectorType, unique.size());
|
|
256
257
|
int index = 0;
|
|
257
258
|
for (JupiterSpec spec : unique.values()) {
|
|
258
259
|
Class<?> type = loadTestClass(spec.className);
|
|
259
|
-
|
|
260
|
+
// Platform 1.7 has selectMethod(Class, String) only. 5.14's two-argument
|
|
261
|
+
// form drops the parameter list and can run nothing, so keep the types
|
|
262
|
+
// when that overload exists.
|
|
263
|
+
Object selector = typed
|
|
264
|
+
? select.invoke(null, new Object[] { type, spec.simple, spec.types })
|
|
265
|
+
: select.invoke(null, new Object[] { type, spec.simple });
|
|
260
266
|
Array.set(selectors, index++, selector);
|
|
261
267
|
}
|
|
262
268
|
Class<?> builderType = Class.forName("org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder");
|
|
@@ -426,13 +432,16 @@ public class ProbatioJacocoRun {
|
|
|
426
432
|
}
|
|
427
433
|
|
|
428
434
|
static java.lang.reflect.Method selectMethodWithTypes(Class<?> selectorsType) throws NoSuchMethodException {
|
|
435
|
+
java.lang.reflect.Method byName = null;
|
|
429
436
|
for (java.lang.reflect.Method candidate : selectorsType.getMethods()) {
|
|
430
437
|
if (!candidate.getName().equals("selectMethod")) continue;
|
|
431
438
|
Class<?>[] params = candidate.getParameterTypes();
|
|
432
439
|
if (params.length == 3 && params[0] == Class.class && params[1] == String.class && params[2].isArray() && params[2].getComponentType() == Class.class) {
|
|
433
440
|
return candidate;
|
|
434
441
|
}
|
|
442
|
+
if (params.length == 2 && params[0] == Class.class && params[1] == String.class) byName = candidate;
|
|
435
443
|
}
|
|
444
|
+
if (byName != null) return byName;
|
|
436
445
|
throw new NoSuchMethodException("selectMethod(Class, String, Class[])");
|
|
437
446
|
}
|
|
438
447
|
|
package/dist/mutate/run.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -13,7 +13,7 @@ import { writeRustCoverage } from "./rust-coverage.js";
|
|
|
13
13
|
import { applyPatch, changedLines, chooseDirection, filesInDiff, git, restoreTree } from "./patch.js";
|
|
14
14
|
import { killLabel } from "./kill-label.js";
|
|
15
15
|
import { TIMEOUT_FLOOR_MS, TIMEOUT_MULTIPLE, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST, WHOLE_SUITE_NEXT, campaignSummary, decideSuite, hasLineMap, noCoverageNext, resumeNext, scaleStop, timeoutLimitMs, } from "./suite-decision.js";
|
|
16
|
-
import { commandSuite, discoverSuite, runDiscoveredSuite, sourceCandidates } from "./suites.js";
|
|
16
|
+
import { commandSuite, discoverSuite, nodeTestTimeoutMs, runDiscoveredSuite, sourceCandidates } from "./suites.js";
|
|
17
17
|
import { duplicateTitles } from "./titles.js";
|
|
18
18
|
export const reporterPath = fileURLToPath(new URL("./fail-reporter.mjs", import.meta.url));
|
|
19
19
|
export function parseReport(stdout) {
|
|
@@ -89,34 +89,61 @@ export async function runMutants(options) {
|
|
|
89
89
|
budgetStarted = Date.now();
|
|
90
90
|
const worker = async (worktree) => {
|
|
91
91
|
const treePkg = packageIn(worktree, repo, packageDir);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
92
|
+
let batch = null;
|
|
93
|
+
const dropBatch = () => {
|
|
94
|
+
batch?.stop();
|
|
95
|
+
batch = null;
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
for (;;) {
|
|
99
|
+
if (startedMutants > 0 && overBudget()) {
|
|
100
|
+
budgetHit = true;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const patch = queue.shift();
|
|
104
|
+
if (!patch)
|
|
105
|
+
return;
|
|
106
|
+
if (startedMutants > 0 && overBudget()) {
|
|
107
|
+
queue.unshift(patch);
|
|
108
|
+
budgetHit = true;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const id = path.basename(patch, ".patch");
|
|
112
|
+
const raw = readFileSync(patch, "utf8");
|
|
113
|
+
const prior = history.get(id);
|
|
114
|
+
if (prior && reusable(prior, raw, treePkg)) {
|
|
115
|
+
startedMutants += 1;
|
|
116
|
+
const reused = reusedResult(prior, commit, run.id, options.agent);
|
|
117
|
+
writeResult(options.outDir, reused);
|
|
118
|
+
progress(`reused ${id}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
109
121
|
startedMutants += 1;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
progress(`${id} start`);
|
|
123
|
+
const plan = batchPlan(raw, prep, options);
|
|
124
|
+
if (plan) {
|
|
125
|
+
if (!batch || batch.dead) {
|
|
126
|
+
dropBatch();
|
|
127
|
+
try {
|
|
128
|
+
batch = await NodeBatch.start(treePkg, options, options.outDir);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
batch = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const launch = plan && batch
|
|
136
|
+
? (pkg, files, names, timeoutMs) => batch.run(pkg, files, names, timeoutMs, id, options.outDir)
|
|
137
|
+
: undefined;
|
|
138
|
+
const result = await runOne(worktree, treePkg, patch, options, commit, run.id, prep, launch);
|
|
139
|
+
writeResult(options.outDir, result);
|
|
140
|
+
if (batch && (batch.dead || result.error === "suite process crashed"))
|
|
141
|
+
dropBatch();
|
|
142
|
+
progress(`${id} ${result.outcome}`);
|
|
114
143
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
writeResult(options.outDir, result);
|
|
119
|
-
progress(`${id} ${result.outcome}`);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
dropBatch();
|
|
120
147
|
}
|
|
121
148
|
};
|
|
122
149
|
await Promise.all(worktrees.dirs.map((dir) => worker(dir)));
|
|
@@ -217,7 +244,7 @@ async function prepare(pkg, options, progress) {
|
|
|
217
244
|
const affectedMap = options.affected ? affectedTests(pkg, { testsDir: options.testsDir }) : null;
|
|
218
245
|
return { ok: true, tests, affectedMap, baselineMs, spec, coverage: readCoverageMap(mapPath) };
|
|
219
246
|
}
|
|
220
|
-
async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
|
|
247
|
+
async function runOne(worktree, pkg, patchPath, options, commit, runId, prep, launch) {
|
|
221
248
|
const id = path.basename(patchPath, ".patch");
|
|
222
249
|
const started = Date.now();
|
|
223
250
|
const raw = readFileSync(patchPath, "utf8");
|
|
@@ -263,7 +290,8 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
|
|
|
263
290
|
return finishMutant(killed(base, direction, ["build"]), pkg, "", false, []);
|
|
264
291
|
}
|
|
265
292
|
const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
|
|
266
|
-
|
|
293
|
+
const forced = options.onlyNames && options.onlyNames.length > 0 ? options.onlyNames : null;
|
|
294
|
+
if (!forced && picked.state === "uncovered") {
|
|
267
295
|
return {
|
|
268
296
|
...base(),
|
|
269
297
|
direction,
|
|
@@ -271,14 +299,20 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
|
|
|
271
299
|
next: noCoverageNext(picked.file, picked.line),
|
|
272
300
|
};
|
|
273
301
|
}
|
|
274
|
-
const names = picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null;
|
|
302
|
+
const names = forced ?? (picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null);
|
|
275
303
|
const forceWhole = picked.state === "covered" && (names === null || names.length === 0);
|
|
276
304
|
// node --test loads every file it is given, so a name pattern still pays for the other files.
|
|
277
305
|
const selected = narrowNodeFiles(pkg, selectTests(files, prep.tests, prep.affectedMap), names, prep.spec.kind);
|
|
278
306
|
const commands = [];
|
|
279
|
-
const
|
|
307
|
+
const named = names && names.length > 0 ? names : null;
|
|
308
|
+
const main = launch
|
|
309
|
+
? await launch(pkg, selected, named, limit)
|
|
310
|
+
: await runSuite(pkg, selected, options, null, named, prep.spec, limit, forceWhole, null, null);
|
|
280
311
|
if (main.command)
|
|
281
312
|
commands.push(main.command);
|
|
313
|
+
if (main.crashed) {
|
|
314
|
+
return finishMutant({ ...base(), direction, outcome: "error", error: "suite process crashed" }, pkg, commands.join(" | "), false, main.selectedTests);
|
|
315
|
+
}
|
|
282
316
|
if (main.timedOut) {
|
|
283
317
|
return finishMutant({ ...base(), direction, outcome: "timeout", error: "suite timed out", next: TIMEOUT_NEXT }, pkg, commands.join(" | "), main.wholeSuite || forceWhole, main.selectedTests);
|
|
284
318
|
}
|
|
@@ -403,6 +437,164 @@ async function runSuite(pkg, files, options, pattern, names, spec, timeoutMs, fo
|
|
|
403
437
|
selectedTests: result.selectedTests,
|
|
404
438
|
};
|
|
405
439
|
}
|
|
440
|
+
function batchPlan(raw, prep, options) {
|
|
441
|
+
if (options.workers !== 1 || prep.spec.kind !== "node")
|
|
442
|
+
return null;
|
|
443
|
+
if (!prep.coverage || Object.keys(prep.coverage.files).length === 0)
|
|
444
|
+
return null;
|
|
445
|
+
const edited = changedLines(raw);
|
|
446
|
+
const files = filesInDiff(raw);
|
|
447
|
+
if (new Set(files.map((item) => item.file)).size !== 1)
|
|
448
|
+
return null;
|
|
449
|
+
const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
|
|
450
|
+
if (picked.state !== "covered")
|
|
451
|
+
return null;
|
|
452
|
+
const names = picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST && name.length > 0);
|
|
453
|
+
if (names.length === 0)
|
|
454
|
+
return null;
|
|
455
|
+
return { names };
|
|
456
|
+
}
|
|
457
|
+
/** Covered node mutants of one file share this process. A crash sets dead and the next mutant starts another. */
|
|
458
|
+
class NodeBatch {
|
|
459
|
+
pid;
|
|
460
|
+
dead = false;
|
|
461
|
+
testTimeoutMs;
|
|
462
|
+
proc;
|
|
463
|
+
constructor(proc, pid, testTimeoutMs) {
|
|
464
|
+
this.proc = proc;
|
|
465
|
+
this.pid = pid;
|
|
466
|
+
this.testTimeoutMs = testTimeoutMs;
|
|
467
|
+
proc.on("close", () => {
|
|
468
|
+
this.dead = true;
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
static async start(pkg, options, outDir) {
|
|
472
|
+
const dir = path.join(outDir, "batch");
|
|
473
|
+
mkdirSync(dir, { recursive: true });
|
|
474
|
+
const ready = path.join(dir, `ready-${process.pid}-${Date.now()}.json`);
|
|
475
|
+
const gen = path.join(dir, `gen-${process.pid}-${Date.now()}.txt`);
|
|
476
|
+
writeFileSync(gen, "0\n");
|
|
477
|
+
const script = fileURLToPath(new URL("./node-batch.mjs", import.meta.url));
|
|
478
|
+
const env = childEnv(options.unset);
|
|
479
|
+
env.PB_READY = ready;
|
|
480
|
+
env.PB_GEN = gen;
|
|
481
|
+
env.PB_ROOT = pkg;
|
|
482
|
+
const proc = spawn(process.execPath, [script], { cwd: pkg, env, stdio: ["pipe", "ignore", "pipe"] });
|
|
483
|
+
const started = Date.now();
|
|
484
|
+
while (!existsSync(ready)) {
|
|
485
|
+
if (proc.exitCode !== null)
|
|
486
|
+
throw new Error("batch process exited before ready");
|
|
487
|
+
if (Date.now() - started > 10_000) {
|
|
488
|
+
proc.kill("SIGKILL");
|
|
489
|
+
throw new Error("batch process did not start");
|
|
490
|
+
}
|
|
491
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
492
|
+
}
|
|
493
|
+
const body = JSON.parse(readFileSync(ready, "utf8"));
|
|
494
|
+
return new NodeBatch(proc, body.pid || proc.pid || 0, options.testTimeoutMs);
|
|
495
|
+
}
|
|
496
|
+
stop() {
|
|
497
|
+
this.dead = true;
|
|
498
|
+
try {
|
|
499
|
+
this.proc.stdin?.end();
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// The pipe is already closed.
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
if (this.proc.pid)
|
|
506
|
+
process.kill(this.proc.pid, "SIGTERM");
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// The process has already exited.
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async run(pkg, files, names, timeoutMs, id, outDir) {
|
|
513
|
+
const selected = names ?? [];
|
|
514
|
+
const replyFile = path.join(outDir, "batch", `${id}.reply.json`);
|
|
515
|
+
rmSync(replyFile, { force: true });
|
|
516
|
+
appendFileSync(path.join(outDir, "batch-processes.jsonl"), `${JSON.stringify({ id, pid: this.pid })}\n`);
|
|
517
|
+
const job = { id, root: pkg, tests: files, names: selected, timeoutMs, testTimeoutMs: nodeTestTimeoutMs(pkg, files, this.testTimeoutMs, timeoutMs), replyFile };
|
|
518
|
+
try {
|
|
519
|
+
this.proc.stdin?.write(`${JSON.stringify(job)}\n`);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
this.dead = true;
|
|
523
|
+
return crashedCall(selected);
|
|
524
|
+
}
|
|
525
|
+
const waited = await waitForReply(replyFile, this.proc, timeoutMs);
|
|
526
|
+
if (waited === "timeout") {
|
|
527
|
+
this.dead = true;
|
|
528
|
+
this.stop();
|
|
529
|
+
return { report: null, timedOut: true, detail: "suite timed out", compileToken: null, command: "", wholeSuite: false, selectedTests: selected };
|
|
530
|
+
}
|
|
531
|
+
if (!waited)
|
|
532
|
+
return crashedCall(selected);
|
|
533
|
+
const failed = waited.failed ?? [];
|
|
534
|
+
const seen = waited.names ?? [];
|
|
535
|
+
if (seen.length === 0 && failed.length === 0) {
|
|
536
|
+
return {
|
|
537
|
+
report: null,
|
|
538
|
+
timedOut: false,
|
|
539
|
+
detail: waited.error || "test reporter returned nothing",
|
|
540
|
+
compileToken: null,
|
|
541
|
+
command: waited.command || "",
|
|
542
|
+
wholeSuite: false,
|
|
543
|
+
selectedTests: selected,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
report: { tests: Math.max(seen.length, failed.length), pass: Math.max(0, seen.length - failed.length), fail: failed.length, failed, names: seen.length > 0 ? seen : selected },
|
|
548
|
+
timedOut: false,
|
|
549
|
+
detail: "",
|
|
550
|
+
compileToken: null,
|
|
551
|
+
command: waited.command || "",
|
|
552
|
+
wholeSuite: false,
|
|
553
|
+
selectedTests: selected,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function crashedCall(selected) {
|
|
558
|
+
return { report: null, timedOut: false, detail: "suite process crashed", compileToken: null, command: "", wholeSuite: false, selectedTests: selected, crashed: true };
|
|
559
|
+
}
|
|
560
|
+
function waitForReply(file, proc, timeoutMs) {
|
|
561
|
+
return new Promise((resolve) => {
|
|
562
|
+
let settled = false;
|
|
563
|
+
const finish = (value) => {
|
|
564
|
+
if (settled)
|
|
565
|
+
return;
|
|
566
|
+
settled = true;
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
clearInterval(poll);
|
|
569
|
+
proc.removeListener("close", onClose);
|
|
570
|
+
resolve(value);
|
|
571
|
+
};
|
|
572
|
+
const read = () => {
|
|
573
|
+
try {
|
|
574
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
const onClose = () => {
|
|
581
|
+
const reply = existsSync(file) ? read() : null;
|
|
582
|
+
finish(reply);
|
|
583
|
+
};
|
|
584
|
+
const timer = setTimeout(() => finish("timeout"), timeoutMs);
|
|
585
|
+
const poll = setInterval(() => {
|
|
586
|
+
if (!existsSync(file))
|
|
587
|
+
return;
|
|
588
|
+
const reply = read();
|
|
589
|
+
if (reply)
|
|
590
|
+
finish(reply);
|
|
591
|
+
}, 20);
|
|
592
|
+
if (proc.exitCode !== null || proc.signalCode !== null)
|
|
593
|
+
onClose();
|
|
594
|
+
else
|
|
595
|
+
proc.once("close", onClose);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
406
598
|
function isNodeTestFile(file) {
|
|
407
599
|
const base = path.posix.basename(file);
|
|
408
600
|
return /\.(test|spec)\.(ts|tsx|mts|js|mjs|cjs)$/.test(base) || /_test\.(ts|tsx|js|mjs)$/.test(base);
|
|
@@ -927,15 +1119,28 @@ function findTestFile(pkg, rel) {
|
|
|
927
1119
|
function sha256(text) {
|
|
928
1120
|
return createHash("sha256").update(text).digest("hex");
|
|
929
1121
|
}
|
|
1122
|
+
const BARE_SUITE = new Set(["command", "suite", "pytest", "unittest", "cobol", "make-test", "c", "cpp"]);
|
|
930
1123
|
function failureId(pkg, failure) {
|
|
931
1124
|
// Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
|
|
932
1125
|
// file doubles it, and pytest then exits 4 and collects nothing.
|
|
933
1126
|
if (pytestNodeId(failure.name))
|
|
934
1127
|
return failure.name;
|
|
935
|
-
|
|
936
|
-
|
|
1128
|
+
// Cargo and dotnet report a bare test name. Go reports a package path that is
|
|
1129
|
+
// not a file, and JUnit reports a classname. The runnable id is the name itself.
|
|
1130
|
+
// An unnamed shell suite stays ::command so a tally does not treat it as a test.
|
|
1131
|
+
if (!failure.file)
|
|
1132
|
+
return BARE_SUITE.has(failure.name) ? `::${failure.name}` : failure.name;
|
|
1133
|
+
if (!testFileOnDisk(pkg, failure.file))
|
|
1134
|
+
return failure.name;
|
|
1135
|
+
const rel = path.relative(pkg, failure.file);
|
|
1136
|
+
const file = !rel || rel.startsWith("..") ? path.basename(failure.file) : rel.split(path.sep).join("/");
|
|
937
1137
|
return `${file}::${failure.name}`;
|
|
938
1138
|
}
|
|
1139
|
+
function testFileOnDisk(pkg, file) {
|
|
1140
|
+
if (path.isAbsolute(file))
|
|
1141
|
+
return existsSync(file);
|
|
1142
|
+
return existsSync(path.join(pkg, file));
|
|
1143
|
+
}
|
|
939
1144
|
function pytestNodeId(name) {
|
|
940
1145
|
const mark = name.indexOf("::");
|
|
941
1146
|
if (mark <= 0)
|
|
@@ -30,7 +30,7 @@ export function timeoutLimitMs(baselineMs, multiple, floorMs) {
|
|
|
30
30
|
return Math.max(floor, scaled);
|
|
31
31
|
}
|
|
32
32
|
/** Kinds that collect a per-line map on the baseline. Other kinds pay the whole suite. */
|
|
33
|
-
const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven", "cargo", "dotnet"]);
|
|
33
|
+
const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven", "cargo", "dotnet", "mocha"]);
|
|
34
34
|
/** A baseline this long, with no line map, is too expensive to repeat for a large batch. */
|
|
35
35
|
export const SLOW_BASELINE_MS = 5_000;
|
|
36
36
|
/** Pending mutants above this count, on a slow unmapped suite, are not started. */
|