probatio 0.0.0-stage → 0.0.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 +83 -2
- package/dist/cli.js +499 -0
- package/dist/contract.js +29 -0
- package/dist/flags.js +87 -0
- package/dist/golden/check.js +188 -0
- package/dist/ledger/build.js +327 -0
- package/dist/matrix/report.js +256 -0
- package/dist/mcp.js +116 -0
- package/dist/memory/state.js +222 -0
- package/dist/mutate/affected.js +173 -0
- package/dist/mutate/coverage-map.js +172 -0
- package/dist/mutate/fail-reporter.mjs +44 -0
- package/dist/mutate/find.js +13 -0
- package/dist/mutate/generate.js +132 -0
- package/dist/mutate/go-coverage.js +100 -0
- package/dist/mutate/ids.js +22 -0
- package/dist/mutate/java-coverage.js +188 -0
- package/dist/mutate/kill-label.js +34 -0
- package/dist/mutate/node-coverage.mjs +245 -0
- package/dist/mutate/operators.js +122 -0
- package/dist/mutate/patch.js +135 -0
- package/dist/mutate/probatio-jacoco-run.java +81 -0
- package/dist/mutate/py-pytest-reporter.py +145 -0
- package/dist/mutate/py-reporter.py +79 -0
- package/dist/mutate/run.js +868 -0
- package/dist/mutate/suite-decision.js +129 -0
- package/dist/mutate/suites.js +1371 -0
- package/dist/mutate/tally.js +175 -0
- package/dist/mutate/text-operators.js +547 -0
- package/dist/mutate/titles.js +23 -0
- package/dist/swarm/check-kill.js +107 -0
- package/dist/swarm/queue.js +62 -0
- package/dist/verify/change.js +262 -0
- package/package.json +33 -4
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export function readHeader(raw) {
|
|
6
|
+
const lines = raw.split(/\r?\n/);
|
|
7
|
+
let direction = null;
|
|
8
|
+
let index = 0;
|
|
9
|
+
while (index < lines.length && !lines[index].startsWith("---") && !lines[index].startsWith("diff ")) {
|
|
10
|
+
const match = lines[index].match(/direction=(forward|reverse)/);
|
|
11
|
+
if (match)
|
|
12
|
+
direction = match[1];
|
|
13
|
+
index++;
|
|
14
|
+
}
|
|
15
|
+
const body = lines.slice(index).join("\n");
|
|
16
|
+
return { direction, body: body.endsWith("\n") || body.length === 0 ? body : `${body}\n` };
|
|
17
|
+
}
|
|
18
|
+
/** `diff -u` patch that applies onto the original and produces the mutant. */
|
|
19
|
+
export function forwardDiff(rel, original, mutated) {
|
|
20
|
+
const dir = mkdtempSync(path.join(tmpdir(), "probatio-diff-"));
|
|
21
|
+
try {
|
|
22
|
+
const before = path.join(dir, "before");
|
|
23
|
+
const after = path.join(dir, "after");
|
|
24
|
+
writeFileSync(before, original);
|
|
25
|
+
writeFileSync(after, mutated);
|
|
26
|
+
const diff = spawnSync("diff", ["-u", "-L", `a/${rel}`, "-L", `b/${rel}`, before, after], { encoding: "utf8" });
|
|
27
|
+
if (diff.status === 0)
|
|
28
|
+
return "";
|
|
29
|
+
if (diff.status !== 1)
|
|
30
|
+
throw new Error(diff.stderr.trim() || "diff failed");
|
|
31
|
+
const body = diff.stdout.endsWith("\n") ? diff.stdout : `${diff.stdout}\n`;
|
|
32
|
+
return `# probatio-mutant direction=forward meaning=apply-to-introduce-the-bug\n${body}`;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function filesInDiff(raw) {
|
|
39
|
+
const body = readHeader(raw).body;
|
|
40
|
+
const files = [];
|
|
41
|
+
const chunks = body.split(/^diff --git .*$/m);
|
|
42
|
+
const pieces = chunks.length > 1 ? chunks.slice(1) : [body];
|
|
43
|
+
for (const piece of pieces) {
|
|
44
|
+
const plus = piece.match(/^\+\+\+ ([^\t\n]+)/m);
|
|
45
|
+
const hunk = piece.match(/^@@ -\d+(?:,\d+)? \+(\d+)/m);
|
|
46
|
+
if (!plus)
|
|
47
|
+
continue;
|
|
48
|
+
let file = plus[1];
|
|
49
|
+
if (file === "/dev/null")
|
|
50
|
+
continue;
|
|
51
|
+
// git diff -R swaps the labels, so the path may be a/ or b/.
|
|
52
|
+
if (file.startsWith("a/") || file.startsWith("b/"))
|
|
53
|
+
file = file.slice(2);
|
|
54
|
+
files.push({ file, line: hunk ? Number(hunk[1]) : 1 });
|
|
55
|
+
}
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Original lines a patch removes or replaces.
|
|
60
|
+
* A unified hunk starts on context, so the hunk header is not the edited line.
|
|
61
|
+
*/
|
|
62
|
+
export function changedLines(raw) {
|
|
63
|
+
const body = readHeader(raw).body;
|
|
64
|
+
const chunks = body.split(/^diff --git .*$/m);
|
|
65
|
+
const pieces = chunks.length > 1 ? chunks.slice(1) : [body];
|
|
66
|
+
const found = [];
|
|
67
|
+
for (const piece of pieces) {
|
|
68
|
+
const plus = piece.match(/^\+\+\+ ([^\t\n]+)/m);
|
|
69
|
+
if (!plus)
|
|
70
|
+
continue;
|
|
71
|
+
let file = plus[1];
|
|
72
|
+
if (file === "/dev/null")
|
|
73
|
+
continue;
|
|
74
|
+
if (file.startsWith("a/") || file.startsWith("b/"))
|
|
75
|
+
file = file.slice(2);
|
|
76
|
+
let oldLine = 0;
|
|
77
|
+
let inHunk = false;
|
|
78
|
+
for (const line of piece.split(/\r?\n/)) {
|
|
79
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+\d+/.exec(line);
|
|
80
|
+
if (hunk) {
|
|
81
|
+
oldLine = Number(hunk[1]);
|
|
82
|
+
inHunk = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!inHunk)
|
|
86
|
+
continue;
|
|
87
|
+
if (line.startsWith("+") || line.startsWith("\\"))
|
|
88
|
+
continue;
|
|
89
|
+
if (line.startsWith("-")) {
|
|
90
|
+
found.push({ file, line: oldLine });
|
|
91
|
+
oldLine += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (line.startsWith(" ")) {
|
|
95
|
+
oldLine += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
inHunk = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
export function git(repo, args, input) {
|
|
104
|
+
const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", input });
|
|
105
|
+
const stderr = [result.stderr ?? "", result.error?.message ?? ""].filter((part) => part.length > 0).join("\n");
|
|
106
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr };
|
|
107
|
+
}
|
|
108
|
+
export function patchApplies(repo, raw, direction) {
|
|
109
|
+
const args = ["apply", "--check", "--whitespace=nowarn", ...(direction === "reverse" ? ["-R"] : []), "-"];
|
|
110
|
+
return git(repo, args, readHeader(raw).body).status === 0;
|
|
111
|
+
}
|
|
112
|
+
export function chooseDirection(repo, raw, requested) {
|
|
113
|
+
if (requested !== "auto")
|
|
114
|
+
return requested;
|
|
115
|
+
const header = readHeader(raw).direction;
|
|
116
|
+
if (header)
|
|
117
|
+
return header;
|
|
118
|
+
if (patchApplies(repo, raw, "forward"))
|
|
119
|
+
return "forward";
|
|
120
|
+
if (patchApplies(repo, raw, "reverse"))
|
|
121
|
+
return "reverse";
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
export function applyPatch(repo, raw, direction) {
|
|
125
|
+
const args = ["apply", "--whitespace=nowarn", ...(direction === "reverse" ? ["-R"] : []), "-"];
|
|
126
|
+
const result = git(repo, args, readHeader(raw).body);
|
|
127
|
+
if (result.status === 0)
|
|
128
|
+
return { ok: true };
|
|
129
|
+
return { ok: false, error: (result.stderr || result.stdout).trim() || "apply failed" };
|
|
130
|
+
}
|
|
131
|
+
/** Put a throwaway worktree back. A diff label is not trusted for the path list. */
|
|
132
|
+
export function restoreTree(repo) {
|
|
133
|
+
git(repo, ["checkout", "--", "."]);
|
|
134
|
+
git(repo, ["clean", "-fd", "-e", "node_modules"]);
|
|
135
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import java.io.ByteArrayInputStream;
|
|
2
|
+
import java.io.File;
|
|
3
|
+
import java.nio.file.Files;
|
|
4
|
+
import java.util.List;
|
|
5
|
+
import org.jacoco.agent.rt.IAgent;
|
|
6
|
+
import org.jacoco.agent.rt.RT;
|
|
7
|
+
import org.jacoco.core.analysis.Analyzer;
|
|
8
|
+
import org.jacoco.core.analysis.CoverageBuilder;
|
|
9
|
+
import org.jacoco.core.analysis.IClassCoverage;
|
|
10
|
+
import org.jacoco.core.analysis.ILine;
|
|
11
|
+
import org.jacoco.core.data.ExecutionDataReader;
|
|
12
|
+
import org.jacoco.core.data.ExecutionDataStore;
|
|
13
|
+
import org.junit.runner.JUnitCore;
|
|
14
|
+
import org.junit.runner.Request;
|
|
15
|
+
|
|
16
|
+
/** One JaCoCo dump per JUnit 4 method. Prints "HIT <file> <line>" lines. */
|
|
17
|
+
public class ProbatioJacocoRun {
|
|
18
|
+
public static void main(String[] args) throws Exception {
|
|
19
|
+
if (args.length != 4) {
|
|
20
|
+
System.err.println("usage: ProbatioJacocoRun classDirs testsFile outFile packageRoot");
|
|
21
|
+
System.exit(2);
|
|
22
|
+
}
|
|
23
|
+
String[] classDirs = args[0].split(File.pathSeparator);
|
|
24
|
+
List<String> tests = Files.readAllLines(new File(args[1]).toPath());
|
|
25
|
+
File out = new File(args[2]);
|
|
26
|
+
File root = new File(args[3]);
|
|
27
|
+
IAgent agent = RT.getAgent();
|
|
28
|
+
StringBuilder text = new StringBuilder();
|
|
29
|
+
for (String spec : tests) {
|
|
30
|
+
int hash = spec.lastIndexOf('#');
|
|
31
|
+
if (hash <= 0) continue;
|
|
32
|
+
String className = spec.substring(0, hash);
|
|
33
|
+
String method = spec.substring(hash + 1);
|
|
34
|
+
agent.getExecutionData(true);
|
|
35
|
+
try {
|
|
36
|
+
new JUnitCore().run(Request.method(Class.forName(className), method));
|
|
37
|
+
} catch (Throwable ignored) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
byte[] data = agent.getExecutionData(false);
|
|
41
|
+
ExecutionDataStore store = new ExecutionDataStore();
|
|
42
|
+
ExecutionDataReader reader = new ExecutionDataReader(new ByteArrayInputStream(data));
|
|
43
|
+
reader.setExecutionDataVisitor(store);
|
|
44
|
+
reader.setSessionInfoVisitor(info -> { });
|
|
45
|
+
reader.read();
|
|
46
|
+
String testName = className + "." + method;
|
|
47
|
+
text.append("TEST ").append(testName).append('\n');
|
|
48
|
+
for (String dir : classDirs) {
|
|
49
|
+
if (dir.isEmpty()) continue;
|
|
50
|
+
File classes = new File(dir);
|
|
51
|
+
if (!classes.isDirectory()) continue;
|
|
52
|
+
CoverageBuilder builder = new CoverageBuilder();
|
|
53
|
+
Analyzer analyzer = new Analyzer(store, builder);
|
|
54
|
+
analyzer.analyzeAll(classes);
|
|
55
|
+
for (IClassCoverage cc : builder.getClasses()) {
|
|
56
|
+
String source = cc.getSourceFileName();
|
|
57
|
+
if (source == null) continue;
|
|
58
|
+
File file = sourceFile(classes, cc.getPackageName(), source);
|
|
59
|
+
if (file == null || !file.isFile()) continue;
|
|
60
|
+
String rel = root.toPath().relativize(file.toPath()).toString().replace('\\', '/');
|
|
61
|
+
int first = cc.getFirstLine();
|
|
62
|
+
int last = cc.getLastLine();
|
|
63
|
+
for (int line = first; line <= last; line++) {
|
|
64
|
+
ILine cov = cc.getLine(line);
|
|
65
|
+
if (cov.getInstructionCounter().getCoveredCount() <= 0) continue;
|
|
66
|
+
text.append("HIT ").append(rel).append(' ').append(line).append(' ').append(testName).append('\n');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
Files.writeString(out.toPath(), text.toString());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static File sourceFile(File classes, String pkg, String source) {
|
|
75
|
+
File target = classes.getParentFile();
|
|
76
|
+
File module = target == null ? null : target.getParentFile();
|
|
77
|
+
if (module == null) return null;
|
|
78
|
+
String rel = (pkg == null || pkg.isEmpty() ? "" : pkg + "/") + source;
|
|
79
|
+
return new File(module, "src/main/java/" + rel);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run pytest and print one Probatio report as a single JSON line.
|
|
3
|
+
|
|
4
|
+
When PROBATIO_COVERAGE_MAP is set and coverage.py imports, also write a
|
|
5
|
+
per-test line map. A missing coverage module leaves the map unwritten.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
# The sysmon core on this interpreter drops dynamic context switches.
|
|
12
|
+
os.environ.setdefault("COVERAGE_CORE", "ctrace")
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Collector:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.names = []
|
|
20
|
+
self.failed = []
|
|
21
|
+
|
|
22
|
+
def pytest_collectreport(self, report):
|
|
23
|
+
if not report.failed:
|
|
24
|
+
return
|
|
25
|
+
self._add(getattr(report, "nodeid", "") or "pytest", report)
|
|
26
|
+
|
|
27
|
+
def pytest_runtest_logreport(self, report):
|
|
28
|
+
if report.when not in ("setup", "call"):
|
|
29
|
+
return
|
|
30
|
+
if report.when == "setup" and not report.failed:
|
|
31
|
+
return
|
|
32
|
+
name = report.nodeid
|
|
33
|
+
if report.when == "call" and name not in self.names:
|
|
34
|
+
self.names.append(name)
|
|
35
|
+
if report.failed:
|
|
36
|
+
self._add(name, report)
|
|
37
|
+
|
|
38
|
+
def _add(self, name, report):
|
|
39
|
+
if any(item["name"] == name for item in self.failed):
|
|
40
|
+
return
|
|
41
|
+
location = getattr(report, "location", None) or ("", 0, "")
|
|
42
|
+
self.failed.append({"name": name, "file": str(location[0] or ""), "line": int(location[1] or 0)})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ContextSwitch:
|
|
46
|
+
def __init__(self, cov):
|
|
47
|
+
self.cov = cov
|
|
48
|
+
|
|
49
|
+
def pytest_runtest_setup(self, item):
|
|
50
|
+
self.cov.switch_context(item.nodeid)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def start_coverage():
|
|
54
|
+
dest = os.environ.get("PROBATIO_COVERAGE_MAP")
|
|
55
|
+
if not dest:
|
|
56
|
+
return None, None
|
|
57
|
+
try:
|
|
58
|
+
import coverage
|
|
59
|
+
except ImportError:
|
|
60
|
+
return None, None
|
|
61
|
+
data_file = dest + ".data"
|
|
62
|
+
try:
|
|
63
|
+
cov = coverage.Coverage(cover_pylib=False, data_file=data_file)
|
|
64
|
+
cov.start()
|
|
65
|
+
return cov, dest
|
|
66
|
+
except Exception:
|
|
67
|
+
return None, None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_coverage(cov, dest):
|
|
71
|
+
try:
|
|
72
|
+
cov.stop()
|
|
73
|
+
cov.save()
|
|
74
|
+
except Exception:
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
data = cov.get_data()
|
|
78
|
+
except Exception:
|
|
79
|
+
return
|
|
80
|
+
root = os.path.realpath(os.getcwd())
|
|
81
|
+
files = {}
|
|
82
|
+
for filename in data.measured_files():
|
|
83
|
+
full = os.path.realpath(filename)
|
|
84
|
+
if not full.startswith(root + os.sep):
|
|
85
|
+
continue
|
|
86
|
+
if "/site-packages/" in full or "/node_modules/" in full:
|
|
87
|
+
continue
|
|
88
|
+
rel = full[len(root) + 1 :]
|
|
89
|
+
try:
|
|
90
|
+
by_line = data.contexts_by_lineno(filename) or {}
|
|
91
|
+
except Exception:
|
|
92
|
+
continue
|
|
93
|
+
lines = {}
|
|
94
|
+
for lineno, contexts in by_line.items():
|
|
95
|
+
names = [item for item in contexts if item]
|
|
96
|
+
if names:
|
|
97
|
+
lines[str(lineno)] = names
|
|
98
|
+
if lines:
|
|
99
|
+
files[rel] = lines
|
|
100
|
+
with open(dest, "w", encoding="utf-8") as handle:
|
|
101
|
+
json.dump({"files": files}, handle)
|
|
102
|
+
handle.write("\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main():
|
|
106
|
+
args = []
|
|
107
|
+
index = 1
|
|
108
|
+
while index < len(sys.argv):
|
|
109
|
+
if sys.argv[index] == "--pattern":
|
|
110
|
+
args.extend(["-k", sys.argv[index + 1]])
|
|
111
|
+
index += 2
|
|
112
|
+
continue
|
|
113
|
+
args.append(sys.argv[index])
|
|
114
|
+
index += 1
|
|
115
|
+
cov, dest = start_coverage()
|
|
116
|
+
collector = Collector()
|
|
117
|
+
plugins = [collector]
|
|
118
|
+
if cov is not None:
|
|
119
|
+
plugins.append(ContextSwitch(cov))
|
|
120
|
+
code = 1
|
|
121
|
+
try:
|
|
122
|
+
code = pytest.main([*args, "-p", "no:cacheprovider", "--tb=no", "-q"], plugins=plugins) or 0
|
|
123
|
+
except BaseException:
|
|
124
|
+
code = 1
|
|
125
|
+
finally:
|
|
126
|
+
if cov is not None and dest:
|
|
127
|
+
write_coverage(cov, dest)
|
|
128
|
+
names = collector.names or [item["name"] for item in collector.failed]
|
|
129
|
+
failed = collector.failed
|
|
130
|
+
# Exit 4 is a usage error (a bad -k expression). No test ran, so this is not a kill.
|
|
131
|
+
if not names and code != 0:
|
|
132
|
+
names = []
|
|
133
|
+
failed = []
|
|
134
|
+
report = {
|
|
135
|
+
"tests": len(names),
|
|
136
|
+
"pass": len(names) - len(failed),
|
|
137
|
+
"fail": len(failed),
|
|
138
|
+
"failed": failed,
|
|
139
|
+
"names": names,
|
|
140
|
+
}
|
|
141
|
+
sys.stdout.write(json.dumps(report) + "\n")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run unittest modules and print one Probatio report as a single JSON line."""
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load(path: Path):
|
|
13
|
+
name = f"probatio_{path.stem}"
|
|
14
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
15
|
+
if spec is None or spec.loader is None:
|
|
16
|
+
raise RuntimeError(f"cannot load {path}")
|
|
17
|
+
module = importlib.util.module_from_spec(spec)
|
|
18
|
+
sys.modules[name] = module
|
|
19
|
+
spec.loader.exec_module(module)
|
|
20
|
+
return module
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def walk(suite):
|
|
24
|
+
for item in suite:
|
|
25
|
+
if isinstance(item, unittest.TestSuite):
|
|
26
|
+
yield from walk(item)
|
|
27
|
+
else:
|
|
28
|
+
yield item
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_args(argv):
|
|
32
|
+
pattern = None
|
|
33
|
+
files = []
|
|
34
|
+
index = 0
|
|
35
|
+
while index < len(argv):
|
|
36
|
+
if argv[index] == "--pattern":
|
|
37
|
+
pattern = argv[index + 1]
|
|
38
|
+
index += 2
|
|
39
|
+
continue
|
|
40
|
+
files.append(argv[index])
|
|
41
|
+
index += 1
|
|
42
|
+
return pattern, files
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
pattern, files = parse_args(sys.argv[1:])
|
|
47
|
+
sys.path.insert(0, str(Path.cwd()))
|
|
48
|
+
loader = unittest.TestLoader()
|
|
49
|
+
suite = unittest.TestSuite()
|
|
50
|
+
compiled = re.compile(pattern) if pattern else None
|
|
51
|
+
for file in files:
|
|
52
|
+
loaded = loader.loadTestsFromModule(load(Path(file)))
|
|
53
|
+
if compiled is None:
|
|
54
|
+
suite.addTest(loaded)
|
|
55
|
+
else:
|
|
56
|
+
for case in walk(loaded):
|
|
57
|
+
if compiled.fullmatch(case.id()):
|
|
58
|
+
suite.addTest(case)
|
|
59
|
+
cases = [case for case in walk(suite) if case is not None]
|
|
60
|
+
names = [case.id() for case in cases]
|
|
61
|
+
with open(os.devnull, "w") as stream:
|
|
62
|
+
result = unittest.TextTestRunner(stream=stream, verbosity=0).run(suite)
|
|
63
|
+
failed = []
|
|
64
|
+
for case, tb in list(result.failures) + list(result.errors):
|
|
65
|
+
frames = re.findall(r'File "([^"]+)", line (\d+)', tb)
|
|
66
|
+
file, line = frames[-1] if frames else ("", "0")
|
|
67
|
+
failed.append({"name": case.id(), "file": file, "line": int(line)})
|
|
68
|
+
report = {
|
|
69
|
+
"tests": len(names),
|
|
70
|
+
"pass": len(names) - len(failed),
|
|
71
|
+
"fail": len(failed),
|
|
72
|
+
"failed": failed,
|
|
73
|
+
"names": names,
|
|
74
|
+
}
|
|
75
|
+
sys.stdout.write(json.dumps(report) + "\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|