testguard-cli 0.5.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 +628 -0
- package/README.md +536 -42
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +166 -2
- package/spec/README.md +29 -10
- package/spec/lib/validate.mjs +140 -8
- package/spec/schemas/baseline.schema.json +13 -5
- 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 +131 -29
- package/spec/schemas/evidence.schema.json +476 -69
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +258 -56
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +68 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +55 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +35 -7
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/gate/changed.mjs +9 -2
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/contention.mjs +60 -0
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +20 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +122 -0
- package/src/probe/probe.mjs +331 -40
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +75 -15
- package/src/probe/runners/index.mjs +79 -8
- package/src/probe/runners/jest.mjs +15 -3
- package/src/probe/runners/playwright.mjs +142 -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 +80 -14
- package/src/probe/runners/vitest.mjs +16 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +310 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +90 -30
- package/src/status/status.mjs +47 -6
- package/src/util/glob.mjs +7 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { npx, runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
|
|
5
|
+
import { globToRegExp } from '../../util/glob.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Playwright runner. Selected **per file**, not per project: a defender that
|
|
9
|
+
* lives under Playwright's `testDir` runs here, everything else runs under
|
|
10
|
+
* the project runner (vitest/jest). One claim may list both kinds. Selected
|
|
11
|
+
* for the whole project only with `--runner playwright`.
|
|
12
|
+
*
|
|
13
|
+
* Report protocol: `--reporter=json` with the JSON written to a file we name
|
|
14
|
+
* (PLAYWRIGHT_JSON_OUTPUT_FILE; the older PLAYWRIGHT_JSON_OUTPUT_NAME is set
|
|
15
|
+
* too), never parsed from stdout. Playwright's test statuses map onto the
|
|
16
|
+
* spec's testRun as follows, and the mapping is the point of the adapter:
|
|
17
|
+
*
|
|
18
|
+
* expected → passed
|
|
19
|
+
* unexpected/failed → failed, counts toward assertionFailures (can kill)
|
|
20
|
+
* unexpected/timedOut → failed, counts toward timeouts (never a kill)
|
|
21
|
+
* flaky → failed on an attempt and passed on retry: the run
|
|
22
|
+
* is NOT green. Counted as failed with neither an
|
|
23
|
+
* assertion failure nor a timeout, so a green-looking
|
|
24
|
+
* exit code becomes FLAKY-DEFENDER through the
|
|
25
|
+
* ordinary N-run rules, exactly as GATE-SEMANTICS says.
|
|
26
|
+
* skipped → neither passed nor failed
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const name = 'playwright';
|
|
30
|
+
|
|
31
|
+
const CONFIG_FILES = ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.js', 'playwright.config.mjs', 'playwright.config.cjs'];
|
|
32
|
+
const SPEC_GLOBS = ['**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx',
|
|
33
|
+
'**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx'];
|
|
34
|
+
|
|
35
|
+
const configCache = new Map();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The project's Playwright config, read as text (it may be TypeScript, so it
|
|
39
|
+
* is never imported): `{ path, testDir }` or null when there is none.
|
|
40
|
+
* `testDir` defaults to the config's own directory, as Playwright does.
|
|
41
|
+
*/
|
|
42
|
+
export function loadConfig(projectDir) {
|
|
43
|
+
if (configCache.has(projectDir)) return configCache.get(projectDir);
|
|
44
|
+
let out = null;
|
|
45
|
+
for (const f of CONFIG_FILES) {
|
|
46
|
+
const path = join(projectDir, f);
|
|
47
|
+
if (!existsSync(path)) continue;
|
|
48
|
+
const text = readFileSync(path, 'utf8');
|
|
49
|
+
const m = /\btestDir\s*:\s*(['"`])([^'"`]+)\1/.exec(text);
|
|
50
|
+
const testDir = relative(projectDir, resolve(projectDir, m ? m[2] : '.')).split(sep).join('/') || '.';
|
|
51
|
+
out = { path: f, testDir };
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
configCache.set(projectDir, out);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Clear the config cache (tests create and delete projects at the same paths). */
|
|
59
|
+
export const resetConfigCache = () => configCache.clear();
|
|
60
|
+
|
|
61
|
+
export const testGlobs = SPEC_GLOBS;
|
|
62
|
+
const SPEC_RES = SPEC_GLOBS.map(globToRegExp);
|
|
63
|
+
|
|
64
|
+
/** Does this test file belong to Playwright: is there a config, and does the file live under its testDir? */
|
|
65
|
+
export function owns(projectDir, file) {
|
|
66
|
+
const cfg = loadConfig(projectDir);
|
|
67
|
+
if (!cfg) return false;
|
|
68
|
+
if (!SPEC_RES.some((re) => re.test(file))) return false;
|
|
69
|
+
return cfg.testDir === '.' ? true : file.startsWith(cfg.testDir + '/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolvable = a config exists AND `@playwright/test` resolves from the
|
|
74
|
+
* project. Deliberately not `npx --no-install playwright --version`: the npx
|
|
75
|
+
* cache and a Python `playwright` on PATH both answer that from a project
|
|
76
|
+
* that has no Playwright at all, and `playwright test` would then run
|
|
77
|
+
* against nothing. The package `playwright test` needs is the precondition.
|
|
78
|
+
*/
|
|
79
|
+
export async function check({ projectDir }) {
|
|
80
|
+
const cfg = loadConfig(projectDir);
|
|
81
|
+
if (!cfg) return { ok: false, message: 'no playwright.config.* in the project' };
|
|
82
|
+
try {
|
|
83
|
+
const pkg = JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve('@playwright/test/package.json'), 'utf8'));
|
|
84
|
+
return { ok: true, version: pkg.version };
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, message: '@playwright/test is not installed in the project (npm i -D @playwright/test)' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const tests = (projectDir) => listTestFiles(projectDir, SPEC_GLOBS).filter((f) => owns(projectDir, f));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Playwright's JSON report → the spec's testRun. Pure; exported for the
|
|
94
|
+
* sample-driven tests. `durationMs` is the wall clock measured by the runner.
|
|
95
|
+
*/
|
|
96
|
+
export function parseReport(report, durationMs) {
|
|
97
|
+
const tests = [];
|
|
98
|
+
const walk = (suite) => {
|
|
99
|
+
for (const spec of suite.specs ?? []) {
|
|
100
|
+
for (const t of spec.tests ?? []) {
|
|
101
|
+
const results = t.results ?? [];
|
|
102
|
+
const last = results[results.length - 1];
|
|
103
|
+
tests.push({ id: `${suite.file ?? spec.file ?? ''}::${spec.title}`, status: t.status, finalResult: last?.status, error: results.find((r) => r.error)?.error?.message });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const s of suite.suites ?? []) walk(s);
|
|
107
|
+
};
|
|
108
|
+
for (const s of report.suites ?? []) walk(s);
|
|
109
|
+
|
|
110
|
+
const expected = tests.filter((t) => t.status === 'expected').length;
|
|
111
|
+
const unexpected = tests.filter((t) => t.status === 'unexpected');
|
|
112
|
+
const flaky = tests.filter((t) => t.status === 'flaky').length;
|
|
113
|
+
const timeouts = unexpected.filter((t) => t.finalResult === 'timedOut' || t.finalResult === 'interrupted').length;
|
|
114
|
+
const assertionFailures = unexpected.length - timeouts;
|
|
115
|
+
const failedTests = [...unexpected, ...tests.filter((t) => t.status === 'flaky')].map((t) => t.id);
|
|
116
|
+
const total = tests.length;
|
|
117
|
+
const summary = { total, passed: expected, failed: unexpected.length + flaky };
|
|
118
|
+
|
|
119
|
+
const globalErrors = report.errors ?? [];
|
|
120
|
+
let outcome;
|
|
121
|
+
if (total === 0) outcome = 'error';
|
|
122
|
+
else if (unexpected.length > 0 || flaky > 0) outcome = 'fail';
|
|
123
|
+
else outcome = 'pass';
|
|
124
|
+
const loadMessage = outcome === 'error'
|
|
125
|
+
? (globalErrors.length ? firstInformativeLine(globalErrors.map((e) => e.message ?? '').join('\n')) : 'playwright found no tests')
|
|
126
|
+
: undefined;
|
|
127
|
+
return { run: { outcome, tests: summary, assertionFailures, durationMs }, timeouts, loadMessage, failedTests, flaky };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const run = (opts) => {
|
|
131
|
+
const env = { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' };
|
|
132
|
+
return runProcess({ ...opts, env, parse: parseReport, argv: (files) => [npx, 'playwright', 'test', '--reporter=json', ...(opts.serial ? ['--workers=1'] : []), ...files] });
|
|
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)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared report writing for TestGuard's two Python reporters.
|
|
3
|
+
|
|
4
|
+
TestGuard never installs anything into the project under test. Both reporters
|
|
5
|
+
are put on PYTHONPATH from the tool's own package and loaded from there, so a
|
|
6
|
+
project with zero test dependencies stays a project with zero test
|
|
7
|
+
dependencies.
|
|
8
|
+
|
|
9
|
+
The report is TestGuard's own shape, not jest's. `unittest` and `pytest` both
|
|
10
|
+
distinguish an assertion failure from a collection/import error natively, and
|
|
11
|
+
that distinction is the whole point of the adapter: flattening it into a
|
|
12
|
+
foreign shape would throw away the best signal Python offers and move the
|
|
13
|
+
verdict semantics out of the tested JavaScript and into here.
|
|
14
|
+
|
|
15
|
+
Shape (schemaVersion 1):
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
"testguard": 1,
|
|
19
|
+
"runner": "pytest" | "unittest",
|
|
20
|
+
"rootdir": "<absolute dir the run started from>",
|
|
21
|
+
"collectionErrors": [{"file": str, "message": str}],
|
|
22
|
+
"tests": [{"id": "<file>::<name>", "file": str, "name": str,
|
|
23
|
+
"outcome": "passed"|"failed"|"skipped"|"xfail"|"xpass",
|
|
24
|
+
"phase": "setup"|"call"|"teardown",
|
|
25
|
+
"exceptionType": str|null, "message": str|null}],
|
|
26
|
+
"provenance": {"<relative target path>": "<absolute path actually
|
|
27
|
+
imported>" | null}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
`provenance` is the Python-specific precondition. A scratch git worktree
|
|
31
|
+
isolates Node for free, because Node loads files by path. Python does not: an
|
|
32
|
+
editable install (PEP 660 puts an import hook ahead of every path entry) or a
|
|
33
|
+
site-packages copy means the interpreter loads the ORIGINAL file while
|
|
34
|
+
TestGuard has faulted the copy. The baseline stays green, every fault
|
|
35
|
+
SURVIVES, and the result is a confident lie. So every reporter reports which
|
|
36
|
+
file the interpreter actually loaded, and the JavaScript side refuses to
|
|
37
|
+
proceed when that is not the file it faulted.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
ENV_OUT = "TESTGUARD_REPORT"
|
|
45
|
+
ENV_TARGETS = "TESTGUARD_TARGETS"
|
|
46
|
+
|
|
47
|
+
MAX_MESSAGE = 4000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def clip(text):
|
|
51
|
+
"""One bounded string. A pytest longrepr can be megabytes; the report is read whole."""
|
|
52
|
+
if text is None:
|
|
53
|
+
return None
|
|
54
|
+
s = str(text)
|
|
55
|
+
return s if len(s) <= MAX_MESSAGE else s[:MAX_MESSAGE] + "\n… (truncated)"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def targets():
|
|
59
|
+
"""Fault target files (project-relative) whose import provenance must be proved."""
|
|
60
|
+
raw = os.environ.get(ENV_TARGETS)
|
|
61
|
+
if not raw:
|
|
62
|
+
return []
|
|
63
|
+
try:
|
|
64
|
+
value = json.loads(raw)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return []
|
|
67
|
+
return [t for t in value if isinstance(t, str)] if isinstance(value, list) else []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def module_tail(rootdir, rel):
|
|
71
|
+
"""
|
|
72
|
+
The trailing path that identifies `rel` as a module wherever it is imported
|
|
73
|
+
from: its basename preceded by every parent directory that is a package
|
|
74
|
+
(`__init__.py`). `src/websec/core.py` with `src/websec/__init__.py` present
|
|
75
|
+
and no `src/__init__.py` gives `websec/core.py` — which is what a
|
|
76
|
+
site-packages copy of the same module would also end with.
|
|
77
|
+
"""
|
|
78
|
+
parts = [os.path.basename(rel)]
|
|
79
|
+
directory = os.path.dirname(os.path.join(rootdir, rel))
|
|
80
|
+
# Bounded: a package nesting deeper than this is pathological, and an
|
|
81
|
+
# unbounded walk on a malformed tree would climb to the filesystem root.
|
|
82
|
+
for _ in range(32):
|
|
83
|
+
if not os.path.isfile(os.path.join(directory, "__init__.py")):
|
|
84
|
+
break
|
|
85
|
+
parts.insert(0, os.path.basename(directory))
|
|
86
|
+
parent = os.path.dirname(directory)
|
|
87
|
+
if parent == directory:
|
|
88
|
+
break
|
|
89
|
+
directory = parent
|
|
90
|
+
return os.path.join(*parts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def provenance(rootdir):
|
|
94
|
+
"""
|
|
95
|
+
For each target: the absolute file the interpreter actually loaded for it,
|
|
96
|
+
or None when no module with that tail was loaded at all.
|
|
97
|
+
|
|
98
|
+
Read from `sys.modules` after the tests have run, so it reflects the real
|
|
99
|
+
`sys.path` the suite ran under — including anything a `conftest.py`, a
|
|
100
|
+
`pyproject.toml` `pythonpath`, or an editable-install hook did to it. A
|
|
101
|
+
separate `find_spec` subprocess could not see any of that.
|
|
102
|
+
"""
|
|
103
|
+
out = {}
|
|
104
|
+
loaded = []
|
|
105
|
+
for module in list(sys.modules.values()):
|
|
106
|
+
path = getattr(module, "__file__", None)
|
|
107
|
+
if not path:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
loaded.append(os.path.realpath(path))
|
|
111
|
+
except OSError:
|
|
112
|
+
continue
|
|
113
|
+
for rel in targets():
|
|
114
|
+
tail = module_tail(rootdir, rel)
|
|
115
|
+
match = None
|
|
116
|
+
for path in loaded:
|
|
117
|
+
if path == tail or path.endswith(os.sep + tail):
|
|
118
|
+
match = path
|
|
119
|
+
break
|
|
120
|
+
out[rel] = match
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def write(runner, rootdir, tests, collection_errors):
|
|
125
|
+
"""Write the report where TestGuard asked for it. Never to stdout: a test may print anything."""
|
|
126
|
+
out_path = os.environ.get(ENV_OUT)
|
|
127
|
+
if not out_path:
|
|
128
|
+
return
|
|
129
|
+
document = {
|
|
130
|
+
"testguard": 1,
|
|
131
|
+
"runner": runner,
|
|
132
|
+
"rootdir": rootdir,
|
|
133
|
+
"collectionErrors": collection_errors,
|
|
134
|
+
"tests": tests,
|
|
135
|
+
"provenance": provenance(rootdir),
|
|
136
|
+
}
|
|
137
|
+
directory = os.path.dirname(out_path)
|
|
138
|
+
if directory and not os.path.isdir(directory):
|
|
139
|
+
os.makedirs(directory, exist_ok=True)
|
|
140
|
+
with open(out_path, "w", encoding="utf-8") as handle:
|
|
141
|
+
json.dump(document, handle)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TestGuard's stdlib `unittest` runner.
|
|
3
|
+
|
|
4
|
+
Run as `python -m _testguard_unittest_main <test files…>` from a PYTHONPATH
|
|
5
|
+
entry inside the tool's own package. It needs nothing installed anywhere: a
|
|
6
|
+
project whose test dependencies are the standard library stays that way, which
|
|
7
|
+
is the whole reason this file exists rather than a pytest plugin requirement.
|
|
8
|
+
|
|
9
|
+
Two deliberate departures from `python -m unittest`:
|
|
10
|
+
|
|
11
|
+
* Test modules are imported here, not by the loader. `unittest` turns an
|
|
12
|
+
import failure into a synthetic `_FailedTest` that *errors at run time* —
|
|
13
|
+
indistinguishable, from the outside, from a test that ran and failed. That
|
|
14
|
+
distinction is the difference between "your tests caught the fault" and
|
|
15
|
+
"the fault stopped the suite from loading", so it is made explicitly.
|
|
16
|
+
|
|
17
|
+
* Nothing is written to stdout or stderr. `unittest`'s own runner prints its
|
|
18
|
+
summary to stderr, where it interleaves with whatever the tests print —
|
|
19
|
+
the exact trap that makes hand-rolled harnesses report a suite as green
|
|
20
|
+
when it is not. The result is a JSON file or nothing at all.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import importlib
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import traceback
|
|
27
|
+
import unittest
|
|
28
|
+
|
|
29
|
+
import _testguard_report as tg
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _module_for(path):
|
|
33
|
+
"""
|
|
34
|
+
`(top_level_dir, dotted_name)` for a test file, the way `unittest discover`
|
|
35
|
+
would resolve it: walk up while the directory is a package, and the first
|
|
36
|
+
directory that is not becomes the import root.
|
|
37
|
+
"""
|
|
38
|
+
absolute = os.path.abspath(path)
|
|
39
|
+
parts = [os.path.splitext(os.path.basename(absolute))[0]]
|
|
40
|
+
directory = os.path.dirname(absolute)
|
|
41
|
+
for _ in range(32):
|
|
42
|
+
if not os.path.isfile(os.path.join(directory, "__init__.py")):
|
|
43
|
+
break
|
|
44
|
+
parts.insert(0, os.path.basename(directory))
|
|
45
|
+
parent = os.path.dirname(directory)
|
|
46
|
+
if parent == directory:
|
|
47
|
+
break
|
|
48
|
+
directory = parent
|
|
49
|
+
return directory, ".".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rel(path):
|
|
53
|
+
if not path:
|
|
54
|
+
return ""
|
|
55
|
+
try:
|
|
56
|
+
return os.path.relpath(path, os.getcwd()).replace(os.sep, "/")
|
|
57
|
+
except ValueError:
|
|
58
|
+
return str(path).replace(os.sep, "/")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _file_of(test):
|
|
62
|
+
module = sys.modules.get(getattr(test, "__module__", "") or getattr(type(test), "__module__", ""))
|
|
63
|
+
return _rel(getattr(module, "__file__", None))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Result(unittest.TestResult):
|
|
67
|
+
"""
|
|
68
|
+
Every outcome `unittest` can produce, kept apart.
|
|
69
|
+
|
|
70
|
+
`addFailure` (an assertion rejected the behaviour) and `addError` (the test
|
|
71
|
+
body raised something else) are both recorded as `failed`: the JavaScript
|
|
72
|
+
runners already treat a thrown TypeError in a test body as the suite
|
|
73
|
+
noticing, and a Python adapter that disagreed would make the same fault
|
|
74
|
+
kill under jest and survive under unittest. The exception class is reported
|
|
75
|
+
alongside so the difference is never lost, only kept out of the verdict.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self):
|
|
79
|
+
super().__init__()
|
|
80
|
+
self.records = {}
|
|
81
|
+
|
|
82
|
+
def _record(self, test):
|
|
83
|
+
key = test.id()
|
|
84
|
+
if key not in self.records:
|
|
85
|
+
file_rel = _file_of(test)
|
|
86
|
+
name = key
|
|
87
|
+
module_name = getattr(test, "__module__", "")
|
|
88
|
+
if module_name and name.startswith(module_name + "."):
|
|
89
|
+
name = name[len(module_name) + 1:]
|
|
90
|
+
self.records[key] = {
|
|
91
|
+
"id": "%s::%s" % (file_rel or "", name),
|
|
92
|
+
"file": file_rel,
|
|
93
|
+
"name": name,
|
|
94
|
+
"outcome": "passed",
|
|
95
|
+
"phase": "call",
|
|
96
|
+
"exceptionType": None,
|
|
97
|
+
"message": None,
|
|
98
|
+
}
|
|
99
|
+
return self.records[key]
|
|
100
|
+
|
|
101
|
+
def _fail(self, test, err, outcome="failed"):
|
|
102
|
+
record = self._record(test)
|
|
103
|
+
record["outcome"] = outcome
|
|
104
|
+
record["exceptionType"] = err[0].__name__ if err and err[0] else None
|
|
105
|
+
record["message"] = tg.clip("".join(traceback.format_exception(*err))) if err else None
|
|
106
|
+
|
|
107
|
+
def startTest(self, test):
|
|
108
|
+
super().startTest(test)
|
|
109
|
+
self._record(test)
|
|
110
|
+
|
|
111
|
+
def addFailure(self, test, err):
|
|
112
|
+
super().addFailure(test, err)
|
|
113
|
+
self._fail(test, err)
|
|
114
|
+
|
|
115
|
+
def addError(self, test, err):
|
|
116
|
+
super().addError(test, err)
|
|
117
|
+
self._fail(test, err)
|
|
118
|
+
|
|
119
|
+
def addSubTest(self, test, subtest, err):
|
|
120
|
+
super().addSubTest(test, subtest, err)
|
|
121
|
+
if err is not None:
|
|
122
|
+
self._fail(test, err)
|
|
123
|
+
|
|
124
|
+
def addSkip(self, test, reason):
|
|
125
|
+
super().addSkip(test, reason)
|
|
126
|
+
record = self._record(test)
|
|
127
|
+
record["outcome"] = "skipped"
|
|
128
|
+
record["message"] = tg.clip(reason)
|
|
129
|
+
|
|
130
|
+
def addExpectedFailure(self, test, err):
|
|
131
|
+
super().addExpectedFailure(test, err)
|
|
132
|
+
self._fail(test, err, outcome="xfail")
|
|
133
|
+
|
|
134
|
+
def addUnexpectedSuccess(self, test):
|
|
135
|
+
super().addUnexpectedSuccess(test)
|
|
136
|
+
self._record(test)["outcome"] = "xpass"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(argv):
|
|
140
|
+
collection_errors = []
|
|
141
|
+
loader = unittest.defaultTestLoader
|
|
142
|
+
suite = unittest.TestSuite()
|
|
143
|
+
|
|
144
|
+
if os.getcwd() not in sys.path:
|
|
145
|
+
sys.path.insert(0, os.getcwd())
|
|
146
|
+
|
|
147
|
+
for path in argv:
|
|
148
|
+
top_level, dotted = _module_for(path)
|
|
149
|
+
if top_level not in sys.path:
|
|
150
|
+
sys.path.insert(0, top_level)
|
|
151
|
+
try:
|
|
152
|
+
module = importlib.import_module(dotted)
|
|
153
|
+
except BaseException: # SyntaxError and SystemExit included: both are load failures, not verdicts
|
|
154
|
+
collection_errors.append({"file": _rel(path), "message": tg.clip(traceback.format_exc())})
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
suite.addTests(loader.loadTestsFromModule(module))
|
|
158
|
+
except BaseException:
|
|
159
|
+
collection_errors.append({"file": _rel(path), "message": tg.clip(traceback.format_exc())})
|
|
160
|
+
|
|
161
|
+
result = Result()
|
|
162
|
+
suite.run(result)
|
|
163
|
+
tg.write("unittest", os.getcwd(), list(result.records.values()), collection_errors)
|
|
164
|
+
return 0 if result.wasSuccessful() and not collection_errors else 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main(sys.argv[1:]))
|