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.
Files changed (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. 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:]))