tdd-cli 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/CHANGELOG.md +18 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/PKG-INFO +1 -1
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/base.py +31 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/pytest_adapter.py +37 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/vitest_adapter.py +40 -1
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/cli.py +10 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_doctor_attribution.py +43 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_suite_overrides.py +179 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/.gitignore +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/LICENSE +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/SECURITY.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/plan.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/pyproject.toml +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/__init__.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/config.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/ledger.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/render.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/conftest.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_contract.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_fleet.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_progress.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_worker_leases.py +0 -0
|
@@ -6,6 +6,24 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.1] - 2026-08-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `tdd doctor` check `default suite cannot reach override files`: when a
|
|
14
|
+
project declares overrides, doctor probes the default suite's discovery
|
|
15
|
+
(pytest: the test command with `--collect-only`; vitest: `vitest list`) and
|
|
16
|
+
fails if it reaches files an override owns — the premise suite overrides
|
|
17
|
+
require, which nothing previously enforced.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- A test observed by more than one suite invocation of the union (the default
|
|
22
|
+
suite's discovery sweeping an override's files, e.g. a bare `pytest` default)
|
|
23
|
+
is now a loud tooling error naming the overlapping tests and the fix, instead
|
|
24
|
+
of the target being silently judged by whichever suite reported it first —
|
|
25
|
+
previously an env-less run whose failure said nothing about the overlap.
|
|
26
|
+
|
|
9
27
|
## [0.2.0] - 2026-08-10
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Ledger-backed TDD process controller for autonomous coding agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/geuben/tdd-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/geuben/tdd-cli
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
+
from collections import Counter
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -42,6 +43,27 @@ class Collection:
|
|
|
42
43
|
failed_files: dict[str, str] = field(default_factory=dict)
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
def _suite_overlap(suite_ids: list[set[str]]) -> list[str]:
|
|
47
|
+
"""Test ids observed by more than one suite invocation of the union (R7.13).
|
|
48
|
+
Overlap means the default command's discovery reaches files an override
|
|
49
|
+
owns, so those tests also ran without the override's command/env — and
|
|
50
|
+
target matching would judge the target by whichever run came first."""
|
|
51
|
+
counts = Counter(i for ids in suite_ids for i in ids)
|
|
52
|
+
return sorted(i for i, n in counts.items() if n > 1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _overlap_error(overlap: list[str]) -> str:
|
|
56
|
+
shown = ", ".join(overlap[:5])
|
|
57
|
+
more = f" (and {len(overlap) - 5} more)" if len(overlap) > 5 else ""
|
|
58
|
+
return (
|
|
59
|
+
f"observed by more than one suite invocation: {shown}{more}."
|
|
60
|
+
" The default suite's discovery reaches files an override owns, so"
|
|
61
|
+
" these tests also ran without the override's command/env. Scope the"
|
|
62
|
+
" default test_command so it cannot reach them (e.g. pass the default"
|
|
63
|
+
" test directories explicitly)."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
45
67
|
def run_command(
|
|
46
68
|
command: str, cwd: Path, timeout: int = 1800,
|
|
47
69
|
extra_env: dict[str, str] | None = None,
|
|
@@ -128,6 +150,15 @@ class Adapter:
|
|
|
128
150
|
def collectable(self) -> GateResult:
|
|
129
151
|
raise NotImplementedError
|
|
130
152
|
|
|
153
|
+
def override_isolation(self) -> GateResult:
|
|
154
|
+
"""Whether the default suite's discovery stays out of files an override
|
|
155
|
+
owns (R7.13's premise). Overlap means runs observe those tests without
|
|
156
|
+
the override's command/env — and the union then holds the same test
|
|
157
|
+
twice with conflicting outcomes. Adapters with a way to probe discovery
|
|
158
|
+
override this; the base answer is ok so third-party adapters without a
|
|
159
|
+
probe don't fail doctor."""
|
|
160
|
+
return GateResult(ok=True)
|
|
161
|
+
|
|
131
162
|
def lint(self) -> GateResult:
|
|
132
163
|
return self._gate(self.project.lint)
|
|
133
164
|
|
|
@@ -25,6 +25,8 @@ from .base import (
|
|
|
25
25
|
Collection,
|
|
26
26
|
GateResult,
|
|
27
27
|
Verdict,
|
|
28
|
+
_overlap_error,
|
|
29
|
+
_suite_overlap,
|
|
28
30
|
run_command,
|
|
29
31
|
)
|
|
30
32
|
|
|
@@ -100,6 +102,7 @@ class PytestAdapter(Adapter):
|
|
|
100
102
|
# agent to rewrite a test that is fine.
|
|
101
103
|
tests: list[dict] = []
|
|
102
104
|
collectors: list[dict] = []
|
|
105
|
+
suite_ids: list[set[str]] = []
|
|
103
106
|
for base_cmd, extra_env in self._suite_invocations():
|
|
104
107
|
report, error = self._suite_report(base_cmd, extra_env)
|
|
105
108
|
if report is None:
|
|
@@ -108,6 +111,12 @@ class PytestAdapter(Adapter):
|
|
|
108
111
|
verdict.duration_ms += int(report.get("duration", 0) * 1000)
|
|
109
112
|
tests.extend(report.get("tests", []))
|
|
110
113
|
collectors.extend(report.get("collectors", []))
|
|
114
|
+
suite_ids.append({t["nodeid"] for t in report.get("tests", [])})
|
|
115
|
+
|
|
116
|
+
overlap = _suite_overlap(suite_ids)
|
|
117
|
+
if overlap:
|
|
118
|
+
verdict.error = _overlap_error(overlap)
|
|
119
|
+
return verdict
|
|
111
120
|
|
|
112
121
|
uncollectable: set[str] = set()
|
|
113
122
|
for collector in collectors:
|
|
@@ -193,6 +202,34 @@ class PytestAdapter(Adapter):
|
|
|
193
202
|
chunks.append(out.strip())
|
|
194
203
|
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
195
204
|
|
|
205
|
+
def override_isolation(self) -> GateResult:
|
|
206
|
+
"""Probes the *test* command's discovery, not `collect_command`'s: the
|
|
207
|
+
test command is what runs at suite time, and a scoped `test_command`
|
|
208
|
+
with a bare per-file `collect_command` is a legitimate registry (the
|
|
209
|
+
per-file command always gets an explicit path). `{workers}` becomes 0 —
|
|
210
|
+
xdist's "no workers" — since discovery needs no parallelism. A probe
|
|
211
|
+
that fails to collect at all is `collectable`'s finding, not this one's."""
|
|
212
|
+
if not self.project.overrides:
|
|
213
|
+
return GateResult(ok=True)
|
|
214
|
+
probe = f"{self._test_cmd().replace('{workers}', '0')} --collect-only -q"
|
|
215
|
+
code, out, err = run_command(probe, self.root)
|
|
216
|
+
reached = sorted({
|
|
217
|
+
f for f in (
|
|
218
|
+
line.split("::", 1)[0]
|
|
219
|
+
for line in out.splitlines()
|
|
220
|
+
if "::" in line
|
|
221
|
+
)
|
|
222
|
+
if self.project.override_for(f)
|
|
223
|
+
})
|
|
224
|
+
if not reached:
|
|
225
|
+
return GateResult(ok=True)
|
|
226
|
+
return GateResult(ok=False, output=(
|
|
227
|
+
"the default suite's discovery reaches files an override owns, so"
|
|
228
|
+
" suite runs would observe them without the override's command/env:"
|
|
229
|
+
f" {', '.join(reached[:5])}. Scope the default test_command so it"
|
|
230
|
+
" cannot reach them (e.g. `pytest tests/`)."
|
|
231
|
+
))
|
|
232
|
+
|
|
196
233
|
def collect(self) -> Collection:
|
|
197
234
|
"""Per file (R10.3) — one uncollectable module must not destroy the whole set."""
|
|
198
235
|
result = Collection()
|
|
@@ -21,6 +21,8 @@ from .base import (
|
|
|
21
21
|
Collection,
|
|
22
22
|
GateResult,
|
|
23
23
|
Verdict,
|
|
24
|
+
_overlap_error,
|
|
25
|
+
_suite_overlap,
|
|
24
26
|
run_command,
|
|
25
27
|
)
|
|
26
28
|
|
|
@@ -61,6 +63,7 @@ class VitestAdapter(Adapter):
|
|
|
61
63
|
# producing no JSON is a loud error, not a silent gap: swallowing it would
|
|
62
64
|
# report a target living in that suite as `not_found`.
|
|
63
65
|
suites: list[dict] = []
|
|
66
|
+
suite_ids: list[set[str]] = []
|
|
64
67
|
for base, extra_env in self._suite_invocations():
|
|
65
68
|
code, out, err = self._run_suite(f"{base} --reporter=json", extra_env)
|
|
66
69
|
report = _extract_json(out)
|
|
@@ -70,7 +73,18 @@ class VitestAdapter(Adapter):
|
|
|
70
73
|
)
|
|
71
74
|
return verdict
|
|
72
75
|
verdict.duration_ms += int(report.get("duration") or 0)
|
|
73
|
-
|
|
76
|
+
results = report.get("testResults", [])
|
|
77
|
+
suites.extend(results)
|
|
78
|
+
suite_ids.append({
|
|
79
|
+
self._id_for(s.get("name", ""), t["fullName"])
|
|
80
|
+
for s in results
|
|
81
|
+
for t in s.get("assertionResults", [])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
overlap = _suite_overlap(suite_ids)
|
|
85
|
+
if overlap:
|
|
86
|
+
verdict.error = _overlap_error(overlap)
|
|
87
|
+
return verdict
|
|
74
88
|
|
|
75
89
|
failed_suites: dict[str, str] = {}
|
|
76
90
|
|
|
@@ -175,6 +189,31 @@ class VitestAdapter(Adapter):
|
|
|
175
189
|
chunks.append((err or out).strip())
|
|
176
190
|
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
177
191
|
|
|
192
|
+
def override_isolation(self) -> GateResult:
|
|
193
|
+
"""Probes with the default `vitest list` — the same stand-in for the
|
|
194
|
+
default run config that `collectable()` already relies on (`vitest run`
|
|
195
|
+
has no listing mode, and running the suite just to enumerate it would
|
|
196
|
+
execute against whatever the tests need live)."""
|
|
197
|
+
if not self.project.overrides:
|
|
198
|
+
return GateResult(ok=True)
|
|
199
|
+
code, out, err = run_command(self._collect_cmd(), self.root)
|
|
200
|
+
reached = sorted({
|
|
201
|
+
f for f in (
|
|
202
|
+
line.strip().partition(" > ")[0]
|
|
203
|
+
for line in out.splitlines()
|
|
204
|
+
if " > " in line
|
|
205
|
+
)
|
|
206
|
+
if self.project.override_for(f)
|
|
207
|
+
})
|
|
208
|
+
if not reached:
|
|
209
|
+
return GateResult(ok=True)
|
|
210
|
+
return GateResult(ok=False, output=(
|
|
211
|
+
"the default config's discovery reaches files an override owns, so"
|
|
212
|
+
" suite runs would observe them without the override's command/env:"
|
|
213
|
+
f" {', '.join(reached[:5])}. Exclude them from the default vitest"
|
|
214
|
+
" config (test.exclude) or scope its include globs."
|
|
215
|
+
))
|
|
216
|
+
|
|
178
217
|
def collect(self) -> Collection:
|
|
179
218
|
result = Collection()
|
|
180
219
|
for path in self._test_files():
|
|
@@ -275,6 +275,16 @@ def cmd_doctor(args) -> Envelope:
|
|
|
275
275
|
gate = adapter.collectable()
|
|
276
276
|
check("collectable", gate.ok, gate.output, project=name)
|
|
277
277
|
|
|
278
|
+
# R7.13's premise — "files the default runner config cannot reach" — is
|
|
279
|
+
# a config property nothing else enforces. Probe it at preflight so the
|
|
280
|
+
# overlap is named here, not discovered as an opaque mid-cycle failure.
|
|
281
|
+
if project.overrides:
|
|
282
|
+
gate = adapter.override_isolation()
|
|
283
|
+
check(
|
|
284
|
+
"default suite cannot reach override files",
|
|
285
|
+
gate.ok, gate.output, project=name,
|
|
286
|
+
)
|
|
287
|
+
|
|
278
288
|
projects[name] = {"ok": all(c["ok"] for c in checks[before:])}
|
|
279
289
|
|
|
280
290
|
for art in cfg.artifacts.values():
|
|
@@ -73,3 +73,46 @@ def test_doctor_does_not_check_node_modules_for_a_pytest_project(repo_multi):
|
|
|
73
73
|
backend_checks = [c for c in checks if c.get("project") == "backend"]
|
|
74
74
|
assert backend_checks, checks
|
|
75
75
|
assert not any(c["check"] == "node_modules present" for c in backend_checks), backend_checks
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_doctor_flags_a_default_suite_that_reaches_override_files(repo):
|
|
79
|
+
"""R7.13's premise — "files the default runner config cannot reach" — is a
|
|
80
|
+
config property nothing else enforces. With a bare `pytest` default, its own
|
|
81
|
+
discovery sweeps the override directory, and every run would observe those
|
|
82
|
+
tests without the override's command/env. Doctor must name the overlap at
|
|
83
|
+
preflight instead of leaving it to fail opaquely mid-cycle."""
|
|
84
|
+
(repo / "backend" / "contract").mkdir()
|
|
85
|
+
(repo / "backend" / "contract" / "test_api.py").write_text(
|
|
86
|
+
"def test_ping():\n assert True\n"
|
|
87
|
+
)
|
|
88
|
+
(repo / "tdd.toml").write_text(
|
|
89
|
+
"[project.backend]\n"
|
|
90
|
+
'root = "backend"\n'
|
|
91
|
+
'adapter = "pytest"\n'
|
|
92
|
+
'test_paths = ["tests/"]\n'
|
|
93
|
+
"[[project.backend.override]]\n"
|
|
94
|
+
'pattern = "contract/"\n'
|
|
95
|
+
'test_command = "pytest contract"\n'
|
|
96
|
+
)
|
|
97
|
+
out = run_cli(repo, "doctor")
|
|
98
|
+
isolation = [
|
|
99
|
+
c for c in out["result"]["checks"]
|
|
100
|
+
if c["check"] == "default suite cannot reach override files"
|
|
101
|
+
]
|
|
102
|
+
assert len(isolation) == 1
|
|
103
|
+
assert isolation[0]["ok"] is False
|
|
104
|
+
assert isolation[0]["project"] == "backend"
|
|
105
|
+
assert "contract/test_api.py" in isolation[0]["detail"]
|
|
106
|
+
|
|
107
|
+
scoped = (repo / "tdd.toml").read_text().replace(
|
|
108
|
+
'test_paths = ["tests/"]\n',
|
|
109
|
+
'test_paths = ["tests/"]\ntest_command = "pytest tests"\n',
|
|
110
|
+
)
|
|
111
|
+
(repo / "tdd.toml").write_text(scoped)
|
|
112
|
+
out = run_cli(repo, "doctor")
|
|
113
|
+
isolation = [
|
|
114
|
+
c for c in out["result"]["checks"]
|
|
115
|
+
if c["check"] == "default suite cannot reach override files"
|
|
116
|
+
]
|
|
117
|
+
assert len(isolation) == 1
|
|
118
|
+
assert isolation[0]["ok"] is True
|
|
@@ -404,3 +404,182 @@ def test_a_cycle_can_target_a_test_only_an_override_suite_reaches(repo):
|
|
|
404
404
|
)
|
|
405
405
|
green = run_cli(repo, "advance")
|
|
406
406
|
assert green["next_action"]["verb"] == "refactor_or_advance", green
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# -- overlap between the default suite and an override suite -------------
|
|
410
|
+
#
|
|
411
|
+
# The feature's premise is "files the default runner config cannot reach". A
|
|
412
|
+
# bare default command (plain `pytest`) quietly breaks that premise: its own
|
|
413
|
+
# discovery sweeps the override directories, the union then holds the same test
|
|
414
|
+
# twice — once observed without the override's command/env — and target
|
|
415
|
+
# matching judged the target by whichever suite reported it first. That failure
|
|
416
|
+
# is opaque (the test fails on some downstream assertion, nothing names the
|
|
417
|
+
# overlap), so it must be a loud typed error instead.
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_pytest_duplicate_nodeid_across_suites_is_a_loud_error(
|
|
421
|
+
tmp_path, monkeypatch
|
|
422
|
+
):
|
|
423
|
+
project = project_with(
|
|
424
|
+
tmp_path, 'test_command = "pytest"\n' + OVERRIDE_BLOCK
|
|
425
|
+
)
|
|
426
|
+
adapter = adapters.build(project, tmp_path)
|
|
427
|
+
duplicated = {
|
|
428
|
+
"nodeid": "contract/test_api.py::test_ping",
|
|
429
|
+
"outcome": "failed",
|
|
430
|
+
"call": {"longrepr": "assert None == '1'"},
|
|
431
|
+
}
|
|
432
|
+
monkeypatch.setattr(
|
|
433
|
+
adapters.base,
|
|
434
|
+
"run_command",
|
|
435
|
+
_fake_pytest_run(
|
|
436
|
+
{
|
|
437
|
+
# Bare default discovery sweeps contract/ too.
|
|
438
|
+
"pytest --json-report": {"duration": 1.0, "tests": [duplicated]},
|
|
439
|
+
"pytest contract": {
|
|
440
|
+
"duration": 2.0,
|
|
441
|
+
"tests": [dict(duplicated, outcome="passed")],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
[],
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
verdict = adapter.run("backend::contract/test_api.py::test_ping")
|
|
448
|
+
assert verdict.error is not None
|
|
449
|
+
assert "contract/test_api.py::test_ping" in verdict.error
|
|
450
|
+
assert "more than one suite" in verdict.error
|
|
451
|
+
assert "test_command" in verdict.error
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_vitest_duplicate_test_id_across_suites_is_a_loud_error(
|
|
455
|
+
tmp_path, monkeypatch
|
|
456
|
+
):
|
|
457
|
+
project = project_with(
|
|
458
|
+
tmp_path,
|
|
459
|
+
'test_command = "npx vitest run"\n'
|
|
460
|
+
"[[project.backend.override]]\n"
|
|
461
|
+
'pattern = "contract/"\n'
|
|
462
|
+
'test_command = "npx vitest run --config vitest.contract.config.ts"\n'
|
|
463
|
+
'collect_command = "npx vitest list --config vitest.contract.config.ts"\n',
|
|
464
|
+
adapter="vitest",
|
|
465
|
+
)
|
|
466
|
+
adapter = adapters.build(project, tmp_path)
|
|
467
|
+
suite_path = str(tmp_path / "backend" / "contract" / "api.test.ts")
|
|
468
|
+
|
|
469
|
+
def result(status):
|
|
470
|
+
return {
|
|
471
|
+
"testResults": [
|
|
472
|
+
{
|
|
473
|
+
"name": suite_path,
|
|
474
|
+
"status": status,
|
|
475
|
+
"assertionResults": [
|
|
476
|
+
{"fullName": "pings", "status": status}
|
|
477
|
+
],
|
|
478
|
+
}
|
|
479
|
+
]
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
483
|
+
if "--config" in command:
|
|
484
|
+
return 0, json.dumps(result("passed")), ""
|
|
485
|
+
return 1, json.dumps(result("failed")), ""
|
|
486
|
+
|
|
487
|
+
monkeypatch.setattr(adapters.base, "run_command", fake)
|
|
488
|
+
verdict = adapter.run("backend::backend/contract/api.test.ts > pings")
|
|
489
|
+
assert verdict.error is not None
|
|
490
|
+
assert "backend/contract/api.test.ts > pings" in verdict.error
|
|
491
|
+
assert "more than one suite" in verdict.error
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def test_pytest_isolation_probe_flags_default_reach_into_override_files(
|
|
495
|
+
tmp_path, monkeypatch
|
|
496
|
+
):
|
|
497
|
+
project = project_with(
|
|
498
|
+
tmp_path, 'test_command = "pytest -n {workers}"\n' + OVERRIDE_BLOCK
|
|
499
|
+
)
|
|
500
|
+
adapter = adapters.build(project, tmp_path)
|
|
501
|
+
seen: list = []
|
|
502
|
+
|
|
503
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
504
|
+
seen.append(command)
|
|
505
|
+
return 0, (
|
|
506
|
+
"tests/test_a.py::test_a\n"
|
|
507
|
+
"contract/test_api.py::test_ping\n"
|
|
508
|
+
"2 tests collected in 0.01s\n"
|
|
509
|
+
), ""
|
|
510
|
+
|
|
511
|
+
monkeypatch.setattr(adapters.pytest_adapter, "run_command", fake)
|
|
512
|
+
gate = adapter.override_isolation()
|
|
513
|
+
assert gate.ok is False
|
|
514
|
+
assert "contract/test_api.py" in gate.output
|
|
515
|
+
assert "test_command" in gate.output
|
|
516
|
+
# The probe asks the *test* command what it would discover — that is the
|
|
517
|
+
# command whose reach matters at run time — with parallelism disabled.
|
|
518
|
+
assert seen == ["pytest -n 0 --collect-only -q"]
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def test_pytest_isolation_probe_passes_when_the_default_suite_is_scoped(
|
|
522
|
+
tmp_path, monkeypatch
|
|
523
|
+
):
|
|
524
|
+
project = project_with(
|
|
525
|
+
tmp_path, 'test_command = "pytest tests"\n' + OVERRIDE_BLOCK
|
|
526
|
+
)
|
|
527
|
+
adapter = adapters.build(project, tmp_path)
|
|
528
|
+
monkeypatch.setattr(
|
|
529
|
+
adapters.pytest_adapter,
|
|
530
|
+
"run_command",
|
|
531
|
+
lambda command, cwd, timeout=1800, extra_env=None: (
|
|
532
|
+
0, "tests/test_a.py::test_a\n", ""
|
|
533
|
+
),
|
|
534
|
+
)
|
|
535
|
+
gate = adapter.override_isolation()
|
|
536
|
+
assert gate.ok is True
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def test_vitest_isolation_probe_flags_default_reach_into_override_files(
|
|
540
|
+
tmp_path, monkeypatch
|
|
541
|
+
):
|
|
542
|
+
project = project_with(
|
|
543
|
+
tmp_path,
|
|
544
|
+
"[[project.backend.override]]\n"
|
|
545
|
+
'pattern = "contract/"\n'
|
|
546
|
+
'test_command = "npx vitest run --config vitest.contract.config.ts"\n'
|
|
547
|
+
'collect_command = "npx vitest list --config vitest.contract.config.ts"\n',
|
|
548
|
+
adapter="vitest",
|
|
549
|
+
)
|
|
550
|
+
adapter = adapters.build(project, tmp_path)
|
|
551
|
+
seen: list = []
|
|
552
|
+
|
|
553
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
554
|
+
seen.append(command)
|
|
555
|
+
return 0, (
|
|
556
|
+
"src/__tests__/a.test.ts > adds\n"
|
|
557
|
+
"contract/api.test.ts > pings\n"
|
|
558
|
+
), ""
|
|
559
|
+
|
|
560
|
+
monkeypatch.setattr(adapters.vitest_adapter, "run_command", fake)
|
|
561
|
+
gate = adapter.override_isolation()
|
|
562
|
+
assert gate.ok is False
|
|
563
|
+
assert "contract/api.test.ts" in gate.output
|
|
564
|
+
assert seen == ["npx vitest list"]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def test_isolation_probe_is_free_when_a_project_declares_no_overrides(
|
|
568
|
+
tmp_path, monkeypatch
|
|
569
|
+
):
|
|
570
|
+
project = project_with(tmp_path, 'test_command = "pytest tests"\n')
|
|
571
|
+
adapter = adapters.build(project, tmp_path)
|
|
572
|
+
|
|
573
|
+
def explode(command, cwd, timeout=1800, extra_env=None):
|
|
574
|
+
raise AssertionError("no probe should run without overrides")
|
|
575
|
+
|
|
576
|
+
monkeypatch.setattr(adapters.pytest_adapter, "run_command", explode)
|
|
577
|
+
assert adapter.override_isolation().ok is True
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def test_overlap_error_truncates_past_five_ids():
|
|
581
|
+
ids = [f"contract/test_api.py::test_{i}" for i in range(7)]
|
|
582
|
+
message = adapters.base._overlap_error(ids)
|
|
583
|
+
assert "test_4" in message and "test_5" not in message
|
|
584
|
+
assert "and 2 more" in message
|
|
585
|
+
assert "test_command" in message
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|