testguard-cli 0.6.0 → 0.7.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/CHANGELOG.md +373 -0
- package/README.md +277 -27
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +112 -5
- package/spec/README.md +28 -10
- package/spec/lib/validate.mjs +84 -8
- package/spec/schemas/baseline.schema.json +7 -7
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +5 -5
- package/spec/schemas/claims.schema.json +122 -25
- package/spec/schemas/common.schema.json +127 -29
- package/spec/schemas/evidence.schema.json +169 -73
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +10 -10
- package/spec/schemas/status.schema.json +25 -25
- package/src/cli.mjs +20 -5
- package/src/commands/claims.mjs +22 -2
- package/src/commands/probe.mjs +24 -4
- package/src/gate/changed.mjs +9 -2
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +14 -6
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +2 -0
- package/src/probe/probe.mjs +235 -52
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +2 -0
- package/src/probe/runners/index.mjs +38 -11
- package/src/probe/runners/jest.mjs +13 -1
- package/src/probe/runners/playwright.mjs +9 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +10 -1
- package/src/probe/runners/vitest.mjs +13 -1
- package/src/replay/replay.mjs +3 -2
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +86 -32
- package/src/util/glob.mjs +7 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, basename } from 'node:path';
|
|
3
|
+
import { walk } from '../util/glob.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Python import and patch analysis: the counterpart of the specifier
|
|
7
|
+
* resolution in `rank.mjs`, which understands only JavaScript.
|
|
8
|
+
*
|
|
9
|
+
* Without it every Python claim that declares no `defendedBy` resolves to no
|
|
10
|
+
* defender and reports `nocover` — not an error, not a warning, just a quiet
|
|
11
|
+
* and wrong answer about a suite that may defend the claim perfectly well.
|
|
12
|
+
*
|
|
13
|
+
* Python is matched on module names rather than on paths, because that is what
|
|
14
|
+
* Python itself matches on: a file at `src/pkg/mod.py` is imported as
|
|
15
|
+
* `pkg.mod` from anywhere on `sys.path`, and the test that reaches it by
|
|
16
|
+
* inserting `src/` into `sys.path` is indistinguishable, at the import site,
|
|
17
|
+
* from one that reaches it through an installed distribution.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const PY = /\.py$/;
|
|
21
|
+
const COMMENT = /^\s*#/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The dotted module name of a file, and the directory that name is relative
|
|
25
|
+
* to: walk up while each directory is a package (`__init__.py`), exactly as
|
|
26
|
+
* Python resolves a module to its package. `src/pkg/mod.py` with
|
|
27
|
+
* `src/pkg/__init__.py` present and no `src/__init__.py` is `pkg.mod` rooted
|
|
28
|
+
* at `src`.
|
|
29
|
+
*
|
|
30
|
+
* A package's own `__init__.py` is the package: `pkg/__init__.py` is `pkg`,
|
|
31
|
+
* not `pkg.__init__`, which is what makes `import pkg.anything` reach it.
|
|
32
|
+
*/
|
|
33
|
+
export function pyModuleName(projectDir, rel) {
|
|
34
|
+
const parts = [basename(rel).replace(PY, '')];
|
|
35
|
+
if (parts[0] === '__init__') parts.pop();
|
|
36
|
+
let dir = dirname(join(projectDir, rel));
|
|
37
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
38
|
+
if (!existsSync(join(dir, '__init__.py'))) break;
|
|
39
|
+
parts.unshift(basename(dir));
|
|
40
|
+
const parent = dirname(dir);
|
|
41
|
+
if (parent === dir) break;
|
|
42
|
+
dir = parent;
|
|
43
|
+
}
|
|
44
|
+
return { dotted: parts.join('.'), isPackage: basename(rel) === '__init__.py' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Logical import lines: `\` continuations joined, and a parenthesised
|
|
49
|
+
* `from x import (a, b)` collapsed onto one line so the names are visible.
|
|
50
|
+
*/
|
|
51
|
+
function logicalLines(source) {
|
|
52
|
+
const raw = source.split('\n');
|
|
53
|
+
const out = [];
|
|
54
|
+
for (let i = 0; i < raw.length; i++) {
|
|
55
|
+
let line = raw[i];
|
|
56
|
+
if (COMMENT.test(line)) continue;
|
|
57
|
+
while (/\\\s*$/.test(line) && i + 1 < raw.length) line = line.replace(/\\\s*$/, ' ') + raw[++i];
|
|
58
|
+
if (/^\s*from\s/.test(line) && line.includes('(') && !line.includes(')')) {
|
|
59
|
+
// Bounded: a malformed file must not make this loop to the end of a large source.
|
|
60
|
+
for (let n = 0; n < 200 && i + 1 < raw.length && !line.includes(')'); n++) line += ' ' + raw[++i];
|
|
61
|
+
}
|
|
62
|
+
out.push(line);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The package a file lives in, as a dotted name ('' when it is not in a package). */
|
|
68
|
+
function packageOf(projectDir, rel) {
|
|
69
|
+
const dir = dirname(rel);
|
|
70
|
+
if (dir === '.' || dir === '') return '';
|
|
71
|
+
return pyModuleName(projectDir, join(dir, '__init__.py')).dotted;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every module path a source imports, as absolute dotted names.
|
|
76
|
+
*
|
|
77
|
+
* `from pkg import mod` is recorded as both `pkg` and `pkg.mod`, because
|
|
78
|
+
* Python cannot tell the two apart at the import site and neither can a
|
|
79
|
+
* reader: whether `mod` is a submodule or an attribute of `pkg` depends on
|
|
80
|
+
* what is on disk. Recording both is the pessimistic reading — it can only
|
|
81
|
+
* make a file count as a defender that might not be, never hide one that is.
|
|
82
|
+
*/
|
|
83
|
+
export function pyImports(source, selfPackage = '') {
|
|
84
|
+
const found = new Set();
|
|
85
|
+
const absolute = (level, module) => {
|
|
86
|
+
if (level === 0) return module;
|
|
87
|
+
const parts = selfPackage ? selfPackage.split('.') : [];
|
|
88
|
+
const base = parts.slice(0, Math.max(0, parts.length - (level - 1)));
|
|
89
|
+
return [...base, ...(module ? module.split('.') : [])].join('.');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
for (const line of logicalLines(source)) {
|
|
93
|
+
const plain = /^\s*import\s+(.+?)\s*$/.exec(line);
|
|
94
|
+
if (plain && !/^\s*import\s*\(/.test(line)) {
|
|
95
|
+
for (const piece of plain[1].split(',')) {
|
|
96
|
+
const name = piece.trim().split(/\s+as\s+/)[0].trim();
|
|
97
|
+
if (/^[\w.]+$/.test(name)) found.add(name);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const rel = /^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+?)\s*$/.exec(line);
|
|
102
|
+
if (rel) {
|
|
103
|
+
const base = absolute(rel[1].length, rel[2]);
|
|
104
|
+
if (base) found.add(base);
|
|
105
|
+
const names = rel[3].replace(/[()]/g, '');
|
|
106
|
+
if (!names.includes('*')) {
|
|
107
|
+
for (const piece of names.split(',')) {
|
|
108
|
+
const name = piece.trim().split(/\s+as\s+/)[0].trim();
|
|
109
|
+
if (/^\w+$/.test(name)) found.add(base ? `${base}.${name}` : name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// `importlib.import_module('pkg.mod')` and `__import__('pkg.mod')`.
|
|
115
|
+
for (const m of source.matchAll(/(?:import_module|__import__)\s*\(\s*(['"])([\w.]+)\1/g)) found.add(m[2]);
|
|
116
|
+
return [...found];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Does `dotted` name the target module, or something inside it when the target is a package's `__init__.py`? */
|
|
120
|
+
function hits(dotted, target) {
|
|
121
|
+
if (!target.dotted) return false;
|
|
122
|
+
return dotted === target.dotted || (target.isPackage && dotted.startsWith(target.dotted + '.'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Does the Python file at `absFile` import `targetRel`? */
|
|
126
|
+
export function pyFileImports(projectDir, absFile, targetRel, testRel) {
|
|
127
|
+
const target = pyModuleName(projectDir, targetRel);
|
|
128
|
+
if (!target.dotted) return false;
|
|
129
|
+
let source;
|
|
130
|
+
try {
|
|
131
|
+
source = readFileSync(absFile, 'utf8');
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return pyImports(source, packageOf(projectDir, testRel ?? absFile)).some((d) => hits(d, target));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Non-test Python files that import the target, by direct import only. */
|
|
139
|
+
export function pyBlastRadius(projectDir, targetRel) {
|
|
140
|
+
const target = pyModuleName(projectDir, targetRel);
|
|
141
|
+
if (!target.dotted) return 0;
|
|
142
|
+
let count = 0;
|
|
143
|
+
for (const rel of walk(projectDir)) {
|
|
144
|
+
if (!PY.test(rel) || rel === targetRel || IS_PY_TEST.test(rel)) continue;
|
|
145
|
+
let source;
|
|
146
|
+
try {
|
|
147
|
+
source = readFileSync(join(projectDir, rel), 'utf8');
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (pyImports(source, packageOf(projectDir, rel)).some((d) => hits(d, target))) count++;
|
|
152
|
+
}
|
|
153
|
+
return count;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const IS_PY_TEST = /(^|\/)(test[^/]*\.py|[^/]*_test\.py)$/;
|
|
157
|
+
|
|
158
|
+
const PATCH_STRING = /\b(?:mock\.|unittest\.mock\.|mocker\.|monkeypatch\.)?(?:patch|setattr|setitem|patch\.object|patch\.dict)\s*\(\s*(['"])([\w.]+)\1/g;
|
|
159
|
+
// `.test()` on a global regex advances its lastIndex and carries that state to
|
|
160
|
+
// the next call. The predicate gets its own non-global copy so line-by-line
|
|
161
|
+
// scanning cannot silently start matching from the middle of a line.
|
|
162
|
+
const PATCH_LINE = new RegExp(PATCH_STRING.source);
|
|
163
|
+
const UNASSERTED = /unasserted:\s*(.+?)\s*$/;
|
|
164
|
+
const ASSERT_LINE = /(^|[^\w.])assert(?:\s|_|\b)|\bself\.assert\w*\s*\(|\bassert\w*\s*\(/;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which parts of the target a test replaces.
|
|
168
|
+
*
|
|
169
|
+
* This is where Python and JavaScript genuinely differ, and copying the
|
|
170
|
+
* JavaScript rule would be wrong. `vi.mock('./mod')` replaces a whole module,
|
|
171
|
+
* so a file that calls it can detect nothing in that module and is never a
|
|
172
|
+
* defender. `patch('pkg.mod.fn')` replaces **one attribute**: a fault anywhere
|
|
173
|
+
* else in `pkg.mod` is still fully detectable, and excluding the file would
|
|
174
|
+
* turn a real verdict into `nocover` — the same silent wrong answer, pointing
|
|
175
|
+
* the other way.
|
|
176
|
+
*
|
|
177
|
+
* So only a patch of the module itself disqualifies a defender. An attribute
|
|
178
|
+
* patch leaves the file a defender and is reported as a signal, because it is
|
|
179
|
+
* still the cheapest available hint about where that test is blind.
|
|
180
|
+
*/
|
|
181
|
+
export function pyPatches(source, target) {
|
|
182
|
+
const whole = [];
|
|
183
|
+
const attributes = [];
|
|
184
|
+
for (const m of source.matchAll(PATCH_STRING)) {
|
|
185
|
+
const path = m[2];
|
|
186
|
+
if (!target.dotted) continue;
|
|
187
|
+
if (path === target.dotted) whole.push(path);
|
|
188
|
+
else if (path.startsWith(target.dotted + '.')) attributes.push(path);
|
|
189
|
+
}
|
|
190
|
+
return { whole, attributes };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Does any assertion in the file mention a name bound from the target? */
|
|
194
|
+
function assertsOnTarget(source, bindings) {
|
|
195
|
+
if (bindings.length === 0) return false;
|
|
196
|
+
for (const line of source.split('\n')) {
|
|
197
|
+
if (COMMENT.test(line) || !ASSERT_LINE.test(line)) continue;
|
|
198
|
+
for (const b of bindings) if (new RegExp(`(^|[^\\w.])${b}(?![\\w])`).test(line)) return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Local names this source binds from the target module. */
|
|
204
|
+
function bindingsFor(source, target, selfPackage) {
|
|
205
|
+
const names = new Set();
|
|
206
|
+
for (const line of logicalLines(source)) {
|
|
207
|
+
const rel = /^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+?)\s*$/.exec(line);
|
|
208
|
+
if (rel) {
|
|
209
|
+
const parts = selfPackage ? selfPackage.split('.') : [];
|
|
210
|
+
const base = rel[1].length === 0 ? rel[2] : [...parts.slice(0, Math.max(0, parts.length - (rel[1].length - 1))), ...(rel[2] ? rel[2].split('.') : [])].join('.');
|
|
211
|
+
const list = rel[3].replace(/[()]/g, '');
|
|
212
|
+
for (const piece of list.split(',')) {
|
|
213
|
+
const [imported, alias] = piece.trim().split(/\s+as\s+/).map((x) => x && x.trim());
|
|
214
|
+
if (!imported || !/^\w+$/.test(imported)) continue;
|
|
215
|
+
if (base === target.dotted) names.add(alias || imported); // from pkg.mod import fn
|
|
216
|
+
else if (`${base}.${imported}` === target.dotted) names.add(alias || imported); // from pkg import mod
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const plain = /^\s*import\s+([\w.]+)(?:\s+as\s+(\w+))?\s*$/.exec(line);
|
|
221
|
+
if (plain && plain[1] === target.dotted) names.add(plain[2] || plain[1].split('.')[0]);
|
|
222
|
+
}
|
|
223
|
+
return [...names];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* How one Python test file relates to one target: does it import it, does it
|
|
228
|
+
* replace the whole module, which attributes of it does it replace, does it
|
|
229
|
+
* ever assert on anything it took from it, and did the author annotate an
|
|
230
|
+
* unasserted patch with a reason (`# unasserted: <why>` on or above the line).
|
|
231
|
+
*/
|
|
232
|
+
export function analyzePyDefender(projectDir, testRel, targetRel) {
|
|
233
|
+
const target = pyModuleName(projectDir, targetRel);
|
|
234
|
+
let source;
|
|
235
|
+
try {
|
|
236
|
+
source = readFileSync(join(projectDir, testRel), 'utf8');
|
|
237
|
+
} catch {
|
|
238
|
+
return { file: testRel, imports: false, mocks: false, asserted: false, annotation: null, patchedAttributes: [] };
|
|
239
|
+
}
|
|
240
|
+
const selfPackage = packageOf(projectDir, testRel);
|
|
241
|
+
const imports = pyImports(source, selfPackage).some((d) => hits(d, target));
|
|
242
|
+
const { whole, attributes } = pyPatches(source, target);
|
|
243
|
+
const bindings = bindingsFor(source, target, selfPackage);
|
|
244
|
+
const asserted = assertsOnTarget(source, bindings);
|
|
245
|
+
|
|
246
|
+
let annotation = null;
|
|
247
|
+
if (whole.length || attributes.length) {
|
|
248
|
+
const lines = source.split('\n');
|
|
249
|
+
lines.forEach((text, i) => {
|
|
250
|
+
if (annotation || !PATCH_LINE.test(text)) return;
|
|
251
|
+
for (let j = i; j >= Math.max(0, i - 2) && !annotation; j--) {
|
|
252
|
+
const a = UNASSERTED.exec(lines[j]);
|
|
253
|
+
if (a) annotation = a[1];
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return { file: testRel, imports, mocks: whole.length > 0, asserted, annotation, patchedAttributes: attributes };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The Python reading of the same question, which is deliberately not the
|
|
262
|
+
* JavaScript one.
|
|
263
|
+
*
|
|
264
|
+
* `vi.mock('./mod')` replaces a module; `patch('pkg.mod.fn')` replaces a single
|
|
265
|
+
* attribute of one. Applying the JavaScript rule to Python would drop every
|
|
266
|
+
* test that patches anything in the target — including tests that defend it
|
|
267
|
+
* perfectly — and report `nocover` for a claim that is in fact well covered.
|
|
268
|
+
* So only a patch of the module itself removes a defender. An attribute patch
|
|
269
|
+
* keeps it and raises `target-attribute-patched`, which names the attributes:
|
|
270
|
+
* a fault in one of them is the place that test is least likely to notice.
|
|
271
|
+
*/
|
|
272
|
+
export function classifyPythonDefenders(projectDir, targetRel, candidates) {
|
|
273
|
+
const canDetect = [];
|
|
274
|
+
const mocking = [];
|
|
275
|
+
const signals = [];
|
|
276
|
+
for (const file of candidates) {
|
|
277
|
+
const a = analyzePyDefender(projectDir, file, targetRel);
|
|
278
|
+
if (a.mocks) {
|
|
279
|
+
mocking.push(file);
|
|
280
|
+
if (!a.asserted) signals.push(a.annotation ? { file, signal: 'unasserted-annotated', reason: a.annotation } : { file, signal: 'mocked-never-asserted' });
|
|
281
|
+
} else {
|
|
282
|
+
canDetect.push(file);
|
|
283
|
+
if (a.patchedAttributes.length) signals.push({ file, signal: 'target-attribute-patched', reason: a.patchedAttributes.slice(0, 8).join(', ') });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { canDetect, mocking, signals };
|
|
287
|
+
}
|
package/src/probe/rank.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve, extname } from 'node:path';
|
|
3
3
|
import { walk } from '../util/glob.mjs';
|
|
4
|
+
import { pyBlastRadius } from './pyimports.mjs';
|
|
4
5
|
|
|
5
6
|
const aliasCache = new Map();
|
|
6
7
|
|
|
@@ -168,6 +169,7 @@ export function fileImports(projectDir, absFile, targetRel) {
|
|
|
168
169
|
* bare package specifiers are not. Direct imports only.
|
|
169
170
|
*/
|
|
170
171
|
export function blastRadius(projectDir, targetRel) {
|
|
172
|
+
if (targetRel.endsWith('.py')) return pyBlastRadius(projectDir, targetRel);
|
|
171
173
|
const targetAbs = resolve(projectDir, targetRel);
|
|
172
174
|
const aliases = loadAliases(projectDir);
|
|
173
175
|
let count = 0;
|
|
@@ -1,32 +1,55 @@
|
|
|
1
1
|
import * as vitest from './vitest.mjs';
|
|
2
2
|
import * as jest from './jest.mjs';
|
|
3
3
|
import * as playwright from './playwright.mjs';
|
|
4
|
+
import * as pythonRunner from './python.mjs';
|
|
4
5
|
|
|
5
|
-
export const RUNNERS = {
|
|
6
|
+
export const RUNNERS = {
|
|
7
|
+
vitest,
|
|
8
|
+
jest,
|
|
9
|
+
playwright,
|
|
10
|
+
python: pythonRunner,
|
|
11
|
+
// The same adapter with its engine pinned: `--runner pytest` fails rather
|
|
12
|
+
// than falling back to unittest, because which engine ran changes what the
|
|
13
|
+
// evidence means.
|
|
14
|
+
pytest: pythonRunner.pinned('pytest'),
|
|
15
|
+
unittest: pythonRunner.pinned('unittest'),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const RUNNER_NAMES = ['vitest', 'jest', 'playwright', 'python', 'pytest', 'unittest'];
|
|
6
19
|
|
|
7
20
|
/**
|
|
8
21
|
* Runners that own files rather than projects: a defender under Playwright's
|
|
9
|
-
* testDir runs under Playwright
|
|
10
|
-
*
|
|
22
|
+
* testDir runs under Playwright, and a `.py` defender runs under Python,
|
|
23
|
+
* whatever the project runner is. That is what makes a repository holding
|
|
24
|
+
* both a JavaScript and a Python package probeable in one run. `auto` may
|
|
25
|
+
* still pick Python as the project runner when no JavaScript runner resolves;
|
|
26
|
+
* it never picks Playwright, which needs a config to mean anything.
|
|
11
27
|
*/
|
|
12
|
-
export const OWNED_RUNNERS = [playwright];
|
|
28
|
+
export const OWNED_RUNNERS = [playwright, pythonRunner];
|
|
13
29
|
|
|
14
30
|
/**
|
|
15
|
-
* Pick the project runner: an explicit name, or the first of vitest, jest
|
|
16
|
-
* is resolvable from the project. Returns
|
|
31
|
+
* Pick the project runner: an explicit name, or the first of vitest, jest,
|
|
32
|
+
* python that is resolvable from the project. Returns
|
|
33
|
+
* { runner, version, source, engine } or { error }. `engine` is set when the
|
|
34
|
+
* runner has more than one (Python: pytest or unittest) and names the one
|
|
35
|
+
* that will actually run — it, not the module name, is what the evidence
|
|
36
|
+
* records.
|
|
17
37
|
*/
|
|
18
|
-
export async function selectRunner({ projectDir, name = 'auto' }) {
|
|
19
|
-
const candidates = name === 'auto' ? [vitest, jest] : [RUNNERS[name]];
|
|
20
|
-
if (!candidates[0]) return { error: `unknown runner "${name}"; use
|
|
38
|
+
export async function selectRunner({ projectDir, sourceDir, python, name = 'auto' }) {
|
|
39
|
+
const candidates = name === 'auto' ? [vitest, jest, pythonRunner] : [RUNNERS[name]];
|
|
40
|
+
if (!candidates[0]) return { error: `unknown runner "${name}"; use ${RUNNER_NAMES.join(', ')} or auto` };
|
|
21
41
|
const messages = [];
|
|
22
42
|
for (const r of candidates) {
|
|
23
|
-
const c = await r.check({ projectDir });
|
|
24
|
-
if (c.ok) return { runner: r, version: c.version, source: c.source };
|
|
43
|
+
const c = await r.check({ projectDir, sourceDir, python });
|
|
44
|
+
if (c.ok) return { runner: r, version: c.version, source: c.source, engine: c.engine };
|
|
25
45
|
messages.push(`${r.name}: ${c.message}`);
|
|
26
46
|
}
|
|
27
47
|
return { error: messages.join('; ') };
|
|
28
48
|
}
|
|
29
49
|
|
|
50
|
+
/** What the evidence calls a runner: the engine that actually ran, else the module's name. */
|
|
51
|
+
export const runnerLabel = (runner, engines) => engines?.get(runner) ?? runner.name;
|
|
52
|
+
|
|
30
53
|
/** The runner a test file runs under: an owning runner if one claims it, else the project runner. */
|
|
31
54
|
export function runnerFor(projectDir, file, primary) {
|
|
32
55
|
if (primary.owns?.(projectDir, file)) return primary;
|
|
@@ -52,6 +75,9 @@ export function partitionByRunner(projectDir, files, primary) {
|
|
|
52
75
|
*/
|
|
53
76
|
export function mergeRuns(parts) {
|
|
54
77
|
if (parts.length === 1) return parts[0];
|
|
78
|
+
// Import provenance is per-target and never contradictory between runners:
|
|
79
|
+
// only the Python runner reports any, and only for its own targets.
|
|
80
|
+
const provenance = Object.assign({}, ...parts.map((p) => p.provenance ?? {}));
|
|
55
81
|
const runs = parts.map((p) => p.run);
|
|
56
82
|
const outcome = runs.some((r) => r.outcome === 'error') ? 'error' : runs.some((r) => r.outcome === 'timeout') ? 'timeout' : runs.some((r) => r.outcome === 'fail') ? 'fail' : 'pass';
|
|
57
83
|
const sum = (k) => runs.reduce((n, r) => n + (r.tests[k] ?? 0), 0);
|
|
@@ -60,5 +86,6 @@ export function mergeRuns(parts) {
|
|
|
60
86
|
timeouts: parts.reduce((n, p) => n + (p.timeouts ?? 0), 0),
|
|
61
87
|
loadMessage: parts.find((p) => p.loadMessage)?.loadMessage,
|
|
62
88
|
failedTests: parts.flatMap((p) => p.failedTests ?? []),
|
|
89
|
+
provenance,
|
|
63
90
|
};
|
|
64
91
|
}
|
|
@@ -6,4 +6,16 @@ export const testGlobs = [...TEST_GLOBS, '**/__tests__/**/*.js', '**/__tests__/*
|
|
|
6
6
|
export const check = (opts) => checkRunner({ ...opts, pkg: 'jest', bin: 'jest' });
|
|
7
7
|
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
|
|
8
8
|
// --runTestsByPath: positionals are exact paths, not regexes — a path with `+` or `(` would otherwise silently match nothing.
|
|
9
|
-
|
|
9
|
+
/** The command line, as data. Exported so it is falsifiable without spawning anything. */
|
|
10
|
+
export const argvFor = (projectDir, files, outFile, { serial = false } = {}) =>
|
|
11
|
+
[...runnerArgv(projectDir, 'jest', 'jest'), '--ci', '--json', `--outputFile=${outFile}`, ...(serial ? ['--runInBand'] : []), '--runTestsByPath', ...files];
|
|
12
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => argvFor(opts.projectDir, files, outFile, opts) });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The negative control's edit: content this runner's loader cannot possibly
|
|
16
|
+
* parse. Used to prove the defenders actually execute a file before a
|
|
17
|
+
* `survived` verdict about it is allowed to stand. An unterminated group is
|
|
18
|
+
* a syntax error at every JavaScript/TypeScript parser, whatever the file
|
|
19
|
+
* contained before.
|
|
20
|
+
*/
|
|
21
|
+
export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';
|
|
@@ -131,3 +131,12 @@ export const run = (opts) => {
|
|
|
131
131
|
const env = { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' };
|
|
132
132
|
return runProcess({ ...opts, env, parse: parseReport, argv: (files) => [npx, 'playwright', 'test', '--reporter=json', ...(opts.serial ? ['--workers=1'] : []), ...files] });
|
|
133
133
|
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The negative control's edit: content this runner's loader cannot possibly
|
|
137
|
+
* parse. Used to prove the defenders actually execute a file before a
|
|
138
|
+
* `survived` verdict about it is allowed to stand. An unterminated group is
|
|
139
|
+
* a syntax error at every JavaScript/TypeScript parser, whatever the file
|
|
140
|
+
* contained before.
|
|
141
|
+
*/
|
|
142
|
+
export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TestGuard's pytest reporter.
|
|
3
|
+
|
|
4
|
+
Loaded with `-p _testguard_pytest_plugin` from a PYTHONPATH entry inside the
|
|
5
|
+
tool's own package. Nothing is installed into the project under test, so
|
|
6
|
+
`pytest` needs no report plugin of its own and the project's dependency set is
|
|
7
|
+
untouched — which matters, because the suites most worth probing are often the
|
|
8
|
+
ones that deliberately have none.
|
|
9
|
+
|
|
10
|
+
Only long-stable hooks are used, so one file serves pytest 7, 8 and 9.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import _testguard_report as tg
|
|
16
|
+
|
|
17
|
+
_tests = {} # nodeid -> record being assembled
|
|
18
|
+
_files = {} # nodeid -> absolute test file
|
|
19
|
+
_collection_errors = []
|
|
20
|
+
|
|
21
|
+
# `unittest.expectedFailure` on a test that passes reaches pytest as a plain
|
|
22
|
+
# failure whose whole report is this string; pytest's own
|
|
23
|
+
# `@pytest.mark.xfail` reports the same situation as a pass carrying
|
|
24
|
+
# `wasxfail`. Both are the suite's expectation being wrong rather than an
|
|
25
|
+
# assertion rejecting the fault, so both must land on `xpass` — otherwise the
|
|
26
|
+
# same test would be kill-eligible under pytest and not under unittest.
|
|
27
|
+
_UNEXPECTED_SUCCESS = "Unexpected success"
|
|
28
|
+
|
|
29
|
+
# Worst-first: a later phase never downgrades a test that already failed.
|
|
30
|
+
_RANK = {"failed": 4, "xpass": 3, "xfail": 2, "skipped": 1, "passed": 0}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _rel(path):
|
|
34
|
+
"""Project-relative, forward-slashed. pytest's rootdir may be an ancestor of the probed project."""
|
|
35
|
+
if not path:
|
|
36
|
+
return ""
|
|
37
|
+
try:
|
|
38
|
+
return os.path.relpath(path, os.getcwd()).replace(os.sep, "/")
|
|
39
|
+
except ValueError: # different drive on Windows
|
|
40
|
+
return str(path).replace(os.sep, "/")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _record(nodeid):
|
|
44
|
+
if nodeid not in _tests:
|
|
45
|
+
file_rel = _rel(_files.get(nodeid)) or nodeid.split("::")[0]
|
|
46
|
+
_tests[nodeid] = {
|
|
47
|
+
"id": nodeid if nodeid.startswith(file_rel) else "%s::%s" % (file_rel, nodeid),
|
|
48
|
+
"file": file_rel,
|
|
49
|
+
"name": nodeid.split("::", 1)[1] if "::" in nodeid else nodeid,
|
|
50
|
+
"outcome": "passed",
|
|
51
|
+
"phase": "call",
|
|
52
|
+
"exceptionType": None,
|
|
53
|
+
"message": None,
|
|
54
|
+
}
|
|
55
|
+
return _tests[nodeid]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _apply(record, outcome, phase, message, exception_type):
|
|
59
|
+
if _RANK[outcome] < _RANK[record["outcome"]]:
|
|
60
|
+
return
|
|
61
|
+
record["outcome"] = outcome
|
|
62
|
+
record["phase"] = phase
|
|
63
|
+
if message is not None:
|
|
64
|
+
record["message"] = tg.clip(message)
|
|
65
|
+
if exception_type is not None:
|
|
66
|
+
record["exceptionType"] = exception_type
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pytest_collection_modifyitems(items):
|
|
70
|
+
"""Absolute path per test, taken while the items still carry it."""
|
|
71
|
+
for item in items:
|
|
72
|
+
path = getattr(item, "path", None) or getattr(item, "fspath", None)
|
|
73
|
+
if path is not None:
|
|
74
|
+
_files[item.nodeid] = str(path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pytest_exception_interact(node, call, report):
|
|
78
|
+
"""
|
|
79
|
+
The exception's class name, which no report object carries. Recorded but
|
|
80
|
+
never allowed to change a verdict: an assertion failure and a TypeError
|
|
81
|
+
raised by the test body are both the suite rejecting the behaviour, and the
|
|
82
|
+
JavaScript runners already treat them alike. It is reported because it is
|
|
83
|
+
the cheapest thing to look at when a kill turns out to be the wrong kind.
|
|
84
|
+
|
|
85
|
+
pytest calls this hook *after* `pytest_runtest_logreport` for the same
|
|
86
|
+
phase, so the record already exists and is amended in place rather than
|
|
87
|
+
read from a map that would still be empty.
|
|
88
|
+
"""
|
|
89
|
+
name = getattr(getattr(call, "excinfo", None), "typename", None)
|
|
90
|
+
nodeid = getattr(report, "nodeid", "") or getattr(node, "nodeid", "")
|
|
91
|
+
if name and nodeid in _tests:
|
|
92
|
+
_tests[nodeid]["exceptionType"] = name
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pytest_collectreport(report):
|
|
96
|
+
"""
|
|
97
|
+
A module that will not import. This is the load failure: it is what a fault
|
|
98
|
+
whose replacement does not parse looks like from here, and it must never be
|
|
99
|
+
mistaken for the tests rejecting the fault.
|
|
100
|
+
"""
|
|
101
|
+
if getattr(report, "failed", False):
|
|
102
|
+
_collection_errors.append({
|
|
103
|
+
"file": _rel(str(getattr(report, "fspath", "") or "")) or report.nodeid,
|
|
104
|
+
"message": tg.clip(report.longrepr),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def pytest_runtest_logreport(report):
|
|
109
|
+
phase = getattr(report, "when", "call")
|
|
110
|
+
record = _record(report.nodeid)
|
|
111
|
+
exception_type = None
|
|
112
|
+
message = tg.clip(report.longrepr) if report.longrepr is not None else None
|
|
113
|
+
|
|
114
|
+
if report.failed:
|
|
115
|
+
unexpected_success = isinstance(message, str) and message.startswith(_UNEXPECTED_SUCCESS)
|
|
116
|
+
_apply(record, "xpass" if unexpected_success else "failed", phase, message, exception_type)
|
|
117
|
+
elif report.skipped:
|
|
118
|
+
# pytest reports an xfail as a skip carrying `wasxfail`.
|
|
119
|
+
_apply(record, "xfail" if hasattr(report, "wasxfail") else "skipped", phase, message, exception_type)
|
|
120
|
+
elif report.passed and hasattr(report, "wasxfail"):
|
|
121
|
+
# A test expected to fail that passed: the suite's own expectation is
|
|
122
|
+
# wrong, so the run is not green — but nothing asserted anything about
|
|
123
|
+
# the fault, so it can never be a kill.
|
|
124
|
+
_apply(record, "xpass", phase, message, exception_type)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def pytest_internalerror(excrepr):
|
|
128
|
+
_collection_errors.append({"file": "", "message": tg.clip(excrepr)})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
132
|
+
tg.write("pytest", os.getcwd(), list(_tests.values()), _collection_errors)
|