tdd-cli 0.2.0__tar.gz → 0.3.0__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.3.0}/CHANGELOG.md +44 -1
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/PKG-INFO +1 -1
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/base.py +55 -8
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/pytest_adapter.py +46 -6
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/vitest_adapter.py +58 -10
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/cli.py +28 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/config.py +13 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_doctor_attribution.py +43 -0
- tdd_cli-0.3.0/tests/test_failure_clipping.py +64 -0
- tdd_cli-0.3.0/tests/test_project_env.py +124 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_suite_overrides.py +182 -3
- tdd_cli-0.3.0/tests/test_target_validation.py +55 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_vitest_adapter.py +30 -2
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/.gitignore +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/LICENSE +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/SECURITY.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/plan.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/pyproject.toml +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/__init__.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/ledger.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/render.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/conftest.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_contract.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_fleet.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_progress.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_worker_leases.py +0 -0
|
@@ -4,7 +4,50 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.3.0] - 2026-08-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `env` on `[project.<name>]`: environment for the default suite's runs and
|
|
12
|
+
collection, with the same semantics as an override's `env` (`${VAR}` expands
|
|
13
|
+
from the environment at invocation). An override's `env` layers on top for
|
|
14
|
+
its own suite. Previously only override suites could declare environment,
|
|
15
|
+
leaving a default suite that reads an endpoint from a variable with no
|
|
16
|
+
registry-level way to receive it (#16).
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- vitest test ids are project-root-relative (`frontend::app/x.test.tsx > name`),
|
|
21
|
+
matching pytest nodeids and the form plan declarations qualify to — they were
|
|
22
|
+
worktree-relative (`frontend::frontend/app/...`), so a declared vitest target
|
|
23
|
+
could never match a verdict: standard cycles limped through on R8.9 adoption
|
|
24
|
+
(a spurious `declared_test_mismatch` per cycle) and pin cycles deadlocked in
|
|
25
|
+
`AWAITING_PIN`, since a pre-existing test is never adoptable (#21).
|
|
26
|
+
- `tdd target` refuses a name that is not a collected test in the cycle's
|
|
27
|
+
projects, suggesting the closest collected ids — previously any string was
|
|
28
|
+
recorded as the target and failed later, misattributed, as `not_found` (#15).
|
|
29
|
+
- Failure text (`target_failure`, uncollected-suite messages) is clipped keeping
|
|
30
|
+
both ends instead of truncated from the head: Python puts the actual error at
|
|
31
|
+
the tail of a traceback, so a head-only cut on a deep stack delivered
|
|
32
|
+
framework frames and cut exactly the line that says what went wrong (#17).
|
|
33
|
+
|
|
34
|
+
## [0.2.1] - 2026-08-10
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `tdd doctor` check `default suite cannot reach override files`: when a
|
|
39
|
+
project declares overrides, doctor probes the default suite's discovery
|
|
40
|
+
(pytest: the test command with `--collect-only`; vitest: `vitest list`) and
|
|
41
|
+
fails if it reaches files an override owns — the premise suite overrides
|
|
42
|
+
require, which nothing previously enforced.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- A test observed by more than one suite invocation of the union (the default
|
|
47
|
+
suite's discovery sweeping an override's files, e.g. a bare `pytest` default)
|
|
48
|
+
is now a loud tooling error naming the overlapping tests and the fix, instead
|
|
49
|
+
of the target being silently judged by whichever suite reported it first —
|
|
50
|
+
previously an env-less run whose failure said nothing about the overlap.
|
|
8
51
|
|
|
9
52
|
## [0.2.0] - 2026-08-10
|
|
10
53
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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,41 @@ 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
|
+
|
|
67
|
+
def clip_failure(text: str, limit: int = 1500) -> str:
|
|
68
|
+
"""Clip failure text to `limit`, keeping both ends. Python puts the actual
|
|
69
|
+
error at the tail of a traceback, so a head-only cut on a deep stack
|
|
70
|
+
(async frameworks, ORMs, HTTP clients) delivered framework frames and cut
|
|
71
|
+
exactly the line that says what went wrong — forcing a re-run outside tdd
|
|
72
|
+
to see an error the tool already had. The head is kept too: for a plain
|
|
73
|
+
assertion failure the first line carries the assertion itself."""
|
|
74
|
+
if len(text) <= limit:
|
|
75
|
+
return text
|
|
76
|
+
head = limit // 5
|
|
77
|
+
tail = limit - head
|
|
78
|
+
return f"{text[:head]}\n… [clipped] …\n{text[-tail:]}"
|
|
79
|
+
|
|
80
|
+
|
|
45
81
|
def run_command(
|
|
46
82
|
command: str, cwd: Path, timeout: int = 1800,
|
|
47
83
|
extra_env: dict[str, str] | None = None,
|
|
@@ -106,17 +142,19 @@ class Adapter:
|
|
|
106
142
|
alternate runner config is still observed — without widening the default
|
|
107
143
|
config, which is exactly the workaround that breaks CI.
|
|
108
144
|
"""
|
|
109
|
-
return [(self._test_cmd(), None)] + [
|
|
110
|
-
(ov.test_command, self.
|
|
145
|
+
return [(self._test_cmd(), self._suite_env(None))] + [
|
|
146
|
+
(ov.test_command, self._suite_env(ov)) for ov in self.project.overrides
|
|
111
147
|
]
|
|
112
148
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
149
|
+
def _suite_env(self, override) -> dict[str, str] | None:
|
|
150
|
+
"""The environment for one suite invocation: the project's `env`, with the
|
|
151
|
+
owning override's layered on top (None means the default suite). `${VAR}`
|
|
152
|
+
references resolve from the environment at invocation time, so a port
|
|
153
|
+
assigned per checkout need not be hard-coded in the reviewed file."""
|
|
154
|
+
merged = {**self.project.env, **(override.env if override else {})}
|
|
155
|
+
if not merged:
|
|
118
156
|
return None
|
|
119
|
-
return {k: os.path.expandvars(v) for k, v in
|
|
157
|
+
return {k: os.path.expandvars(v) for k, v in merged.items()}
|
|
120
158
|
|
|
121
159
|
def stub_hint(self) -> str:
|
|
122
160
|
"""The language idiom for a stub body, quoted into the create_stub directive."""
|
|
@@ -128,6 +166,15 @@ class Adapter:
|
|
|
128
166
|
def collectable(self) -> GateResult:
|
|
129
167
|
raise NotImplementedError
|
|
130
168
|
|
|
169
|
+
def override_isolation(self) -> GateResult:
|
|
170
|
+
"""Whether the default suite's discovery stays out of files an override
|
|
171
|
+
owns (R7.13's premise). Overlap means runs observe those tests without
|
|
172
|
+
the override's command/env — and the union then holds the same test
|
|
173
|
+
twice with conflicting outcomes. Adapters with a way to probe discovery
|
|
174
|
+
override this; the base answer is ok so third-party adapters without a
|
|
175
|
+
probe don't fail doctor."""
|
|
176
|
+
return GateResult(ok=True)
|
|
177
|
+
|
|
131
178
|
def lint(self) -> GateResult:
|
|
132
179
|
return self._gate(self.project.lint)
|
|
133
180
|
|
|
@@ -25,6 +25,9 @@ from .base import (
|
|
|
25
25
|
Collection,
|
|
26
26
|
GateResult,
|
|
27
27
|
Verdict,
|
|
28
|
+
_overlap_error,
|
|
29
|
+
_suite_overlap,
|
|
30
|
+
clip_failure,
|
|
28
31
|
run_command,
|
|
29
32
|
)
|
|
30
33
|
|
|
@@ -100,6 +103,7 @@ class PytestAdapter(Adapter):
|
|
|
100
103
|
# agent to rewrite a test that is fine.
|
|
101
104
|
tests: list[dict] = []
|
|
102
105
|
collectors: list[dict] = []
|
|
106
|
+
suite_ids: list[set[str]] = []
|
|
103
107
|
for base_cmd, extra_env in self._suite_invocations():
|
|
104
108
|
report, error = self._suite_report(base_cmd, extra_env)
|
|
105
109
|
if report is None:
|
|
@@ -108,6 +112,12 @@ class PytestAdapter(Adapter):
|
|
|
108
112
|
verdict.duration_ms += int(report.get("duration", 0) * 1000)
|
|
109
113
|
tests.extend(report.get("tests", []))
|
|
110
114
|
collectors.extend(report.get("collectors", []))
|
|
115
|
+
suite_ids.append({t["nodeid"] for t in report.get("tests", [])})
|
|
116
|
+
|
|
117
|
+
overlap = _suite_overlap(suite_ids)
|
|
118
|
+
if overlap:
|
|
119
|
+
verdict.error = _overlap_error(overlap)
|
|
120
|
+
return verdict
|
|
111
121
|
|
|
112
122
|
uncollectable: set[str] = set()
|
|
113
123
|
for collector in collectors:
|
|
@@ -130,12 +140,14 @@ class PytestAdapter(Adapter):
|
|
|
130
140
|
if hit is not None:
|
|
131
141
|
verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
|
|
132
142
|
call = hit.get("call") or hit.get("setup") or {}
|
|
133
|
-
verdict.target_failure = str(call.get("longrepr", ""))
|
|
143
|
+
verdict.target_failure = clip_failure(str(call.get("longrepr", "")))
|
|
134
144
|
else:
|
|
135
145
|
target_file = native.split("::", 1)[0]
|
|
136
146
|
if any(c == target_file or c.startswith(target_file) for c in uncollectable):
|
|
137
147
|
verdict.target_outcome = NOT_COLLECTED
|
|
138
|
-
verdict.target_failure =
|
|
148
|
+
verdict.target_failure = clip_failure(
|
|
149
|
+
self._collector_error(collectors, target_file)
|
|
150
|
+
)
|
|
139
151
|
else:
|
|
140
152
|
verdict.target_outcome = NOT_FOUND
|
|
141
153
|
return verdict
|
|
@@ -153,8 +165,8 @@ class PytestAdapter(Adapter):
|
|
|
153
165
|
`test_command` — pytest's `--collect-only` composes with any run command."""
|
|
154
166
|
ov = self.project.override_for(rel)
|
|
155
167
|
if ov is None:
|
|
156
|
-
return self._collect_cmd(), None
|
|
157
|
-
return ov.collect_command or ov.test_command, self.
|
|
168
|
+
return self._collect_cmd(), self._suite_env(None)
|
|
169
|
+
return ov.collect_command or ov.test_command, self._suite_env(ov)
|
|
158
170
|
|
|
159
171
|
def _test_files(self) -> list[Path]:
|
|
160
172
|
found: list[Path] = []
|
|
@@ -181,8 +193,8 @@ class PytestAdapter(Adapter):
|
|
|
181
193
|
loses the real error and the failure surfaces unattributed.
|
|
182
194
|
"""
|
|
183
195
|
chunks = []
|
|
184
|
-
probes = [(self._collect_cmd(), None)] + [
|
|
185
|
-
(ov.collect_command or ov.test_command, self.
|
|
196
|
+
probes = [(self._collect_cmd(), self._suite_env(None))] + [
|
|
197
|
+
(ov.collect_command or ov.test_command, self._suite_env(ov))
|
|
186
198
|
for ov in self.project.overrides
|
|
187
199
|
]
|
|
188
200
|
for cmd, env in probes:
|
|
@@ -193,6 +205,34 @@ class PytestAdapter(Adapter):
|
|
|
193
205
|
chunks.append(out.strip())
|
|
194
206
|
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
195
207
|
|
|
208
|
+
def override_isolation(self) -> GateResult:
|
|
209
|
+
"""Probes the *test* command's discovery, not `collect_command`'s: the
|
|
210
|
+
test command is what runs at suite time, and a scoped `test_command`
|
|
211
|
+
with a bare per-file `collect_command` is a legitimate registry (the
|
|
212
|
+
per-file command always gets an explicit path). `{workers}` becomes 0 —
|
|
213
|
+
xdist's "no workers" — since discovery needs no parallelism. A probe
|
|
214
|
+
that fails to collect at all is `collectable`'s finding, not this one's."""
|
|
215
|
+
if not self.project.overrides:
|
|
216
|
+
return GateResult(ok=True)
|
|
217
|
+
probe = f"{self._test_cmd().replace('{workers}', '0')} --collect-only -q"
|
|
218
|
+
code, out, err = run_command(probe, self.root, extra_env=self._suite_env(None))
|
|
219
|
+
reached = sorted({
|
|
220
|
+
f for f in (
|
|
221
|
+
line.split("::", 1)[0]
|
|
222
|
+
for line in out.splitlines()
|
|
223
|
+
if "::" in line
|
|
224
|
+
)
|
|
225
|
+
if self.project.override_for(f)
|
|
226
|
+
})
|
|
227
|
+
if not reached:
|
|
228
|
+
return GateResult(ok=True)
|
|
229
|
+
return GateResult(ok=False, output=(
|
|
230
|
+
"the default suite's discovery reaches files an override owns, so"
|
|
231
|
+
" suite runs would observe them without the override's command/env:"
|
|
232
|
+
f" {', '.join(reached[:5])}. Scope the default test_command so it"
|
|
233
|
+
" cannot reach them (e.g. `pytest tests/`)."
|
|
234
|
+
))
|
|
235
|
+
|
|
196
236
|
def collect(self) -> Collection:
|
|
197
237
|
"""Per file (R10.3) — one uncollectable module must not destroy the whole set."""
|
|
198
238
|
result = Collection()
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""vitest adapter.
|
|
2
2
|
|
|
3
|
-
Test ids are `<
|
|
4
|
-
space-joined ancestorTitles plus the test title.
|
|
5
|
-
|
|
3
|
+
Test ids are `<project-root-relative file> > <fullName>`, where fullName is the
|
|
4
|
+
space-joined ancestorTitles plus the test title. Root-relative matches the pytest
|
|
5
|
+
adapter's nodeids and — decisively — `Engine._qualify`, which strips the project
|
|
6
|
+
root from plan declarations; a worktree-relative id here can never equal a
|
|
7
|
+
declared target. vitest may prefix its JSON with non-JSON lines, so the payload
|
|
8
|
+
is located rather than assumed (R10.2).
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
from __future__ import annotations
|
|
@@ -21,6 +24,9 @@ from .base import (
|
|
|
21
24
|
Collection,
|
|
22
25
|
GateResult,
|
|
23
26
|
Verdict,
|
|
27
|
+
_overlap_error,
|
|
28
|
+
_suite_overlap,
|
|
29
|
+
clip_failure,
|
|
24
30
|
run_command,
|
|
25
31
|
)
|
|
26
32
|
|
|
@@ -44,7 +50,7 @@ class VitestAdapter(Adapter):
|
|
|
44
50
|
def _id_for(self, suite_path: str, full_name: str) -> str:
|
|
45
51
|
abs_path = Path(suite_path)
|
|
46
52
|
try:
|
|
47
|
-
rel = os.path.relpath(abs_path, self.
|
|
53
|
+
rel = os.path.relpath(abs_path, self.root)
|
|
48
54
|
except ValueError:
|
|
49
55
|
rel = suite_path
|
|
50
56
|
return self.qualify(f"{rel} > {full_name}")
|
|
@@ -61,6 +67,7 @@ class VitestAdapter(Adapter):
|
|
|
61
67
|
# producing no JSON is a loud error, not a silent gap: swallowing it would
|
|
62
68
|
# report a target living in that suite as `not_found`.
|
|
63
69
|
suites: list[dict] = []
|
|
70
|
+
suite_ids: list[set[str]] = []
|
|
64
71
|
for base, extra_env in self._suite_invocations():
|
|
65
72
|
code, out, err = self._run_suite(f"{base} --reporter=json", extra_env)
|
|
66
73
|
report = _extract_json(out)
|
|
@@ -70,7 +77,18 @@ class VitestAdapter(Adapter):
|
|
|
70
77
|
)
|
|
71
78
|
return verdict
|
|
72
79
|
verdict.duration_ms += int(report.get("duration") or 0)
|
|
73
|
-
|
|
80
|
+
results = report.get("testResults", [])
|
|
81
|
+
suites.extend(results)
|
|
82
|
+
suite_ids.append({
|
|
83
|
+
self._id_for(s.get("name", ""), t["fullName"])
|
|
84
|
+
for s in results
|
|
85
|
+
for t in s.get("assertionResults", [])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
overlap = _suite_overlap(suite_ids)
|
|
89
|
+
if overlap:
|
|
90
|
+
verdict.error = _overlap_error(overlap)
|
|
91
|
+
return verdict
|
|
74
92
|
|
|
75
93
|
failed_suites: dict[str, str] = {}
|
|
76
94
|
|
|
@@ -78,7 +96,7 @@ class VitestAdapter(Adapter):
|
|
|
78
96
|
suite_path = suite.get("name", "")
|
|
79
97
|
assertions = suite.get("assertionResults", [])
|
|
80
98
|
if not assertions and suite.get("status") == "failed":
|
|
81
|
-
failed_suites[suite_path] = str(suite.get("message", ""))
|
|
99
|
+
failed_suites[suite_path] = clip_failure(str(suite.get("message", "")))
|
|
82
100
|
for t in assertions:
|
|
83
101
|
qualified = self._id_for(suite_path, t["fullName"])
|
|
84
102
|
if t["status"] == "passed":
|
|
@@ -99,7 +117,8 @@ class VitestAdapter(Adapter):
|
|
|
99
117
|
for t in suite.get("assertionResults", []):
|
|
100
118
|
if self._id_for(suite.get("name", ""), t["fullName"]) == target:
|
|
101
119
|
verdict.target_failure = "\n".join(
|
|
102
|
-
m
|
|
120
|
+
clip_failure(m, 600)
|
|
121
|
+
for m in t.get("failureMessages", [])[:3]
|
|
103
122
|
)
|
|
104
123
|
return verdict
|
|
105
124
|
|
|
@@ -157,7 +176,9 @@ class VitestAdapter(Adapter):
|
|
|
157
176
|
suite — against a live backend — just to enumerate it.
|
|
158
177
|
"""
|
|
159
178
|
chunks = []
|
|
160
|
-
code, out, err = run_command(
|
|
179
|
+
code, out, err = run_command(
|
|
180
|
+
self._collect_cmd(), self.root, extra_env=self._suite_env(None)
|
|
181
|
+
)
|
|
161
182
|
if code != 0:
|
|
162
183
|
chunks.append((err or out).strip())
|
|
163
184
|
for ov in self.project.overrides:
|
|
@@ -169,12 +190,39 @@ class VitestAdapter(Adapter):
|
|
|
169
190
|
)
|
|
170
191
|
continue
|
|
171
192
|
code, out, err = run_command(
|
|
172
|
-
ov.collect_command, self.root, extra_env=self.
|
|
193
|
+
ov.collect_command, self.root, extra_env=self._suite_env(ov)
|
|
173
194
|
)
|
|
174
195
|
if code != 0:
|
|
175
196
|
chunks.append((err or out).strip())
|
|
176
197
|
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
177
198
|
|
|
199
|
+
def override_isolation(self) -> GateResult:
|
|
200
|
+
"""Probes with the default `vitest list` — the same stand-in for the
|
|
201
|
+
default run config that `collectable()` already relies on (`vitest run`
|
|
202
|
+
has no listing mode, and running the suite just to enumerate it would
|
|
203
|
+
execute against whatever the tests need live)."""
|
|
204
|
+
if not self.project.overrides:
|
|
205
|
+
return GateResult(ok=True)
|
|
206
|
+
code, out, err = run_command(
|
|
207
|
+
self._collect_cmd(), self.root, extra_env=self._suite_env(None)
|
|
208
|
+
)
|
|
209
|
+
reached = sorted({
|
|
210
|
+
f for f in (
|
|
211
|
+
line.strip().partition(" > ")[0]
|
|
212
|
+
for line in out.splitlines()
|
|
213
|
+
if " > " in line
|
|
214
|
+
)
|
|
215
|
+
if self.project.override_for(f)
|
|
216
|
+
})
|
|
217
|
+
if not reached:
|
|
218
|
+
return GateResult(ok=True)
|
|
219
|
+
return GateResult(ok=False, output=(
|
|
220
|
+
"the default config's discovery reaches files an override owns, so"
|
|
221
|
+
" suite runs would observe them without the override's command/env:"
|
|
222
|
+
f" {', '.join(reached[:5])}. Exclude them from the default vitest"
|
|
223
|
+
" config (test.exclude) or scope its include globs."
|
|
224
|
+
))
|
|
225
|
+
|
|
178
226
|
def collect(self) -> Collection:
|
|
179
227
|
result = Collection()
|
|
180
228
|
for path in self._test_files():
|
|
@@ -187,7 +235,7 @@ class VitestAdapter(Adapter):
|
|
|
187
235
|
)
|
|
188
236
|
continue
|
|
189
237
|
base = ov.collect_command if ov else self._collect_cmd()
|
|
190
|
-
env = self.
|
|
238
|
+
env = self._suite_env(ov)
|
|
191
239
|
code, out, err = run_command(
|
|
192
240
|
f"{base} {shlex.quote(str(rel))}", self.root, extra_env=env
|
|
193
241
|
)
|
|
@@ -6,6 +6,7 @@ No command accepts a phase, a cycle number, or executor identity (R8.3).
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
|
+
import difflib
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import socket
|
|
@@ -275,6 +276,16 @@ def cmd_doctor(args) -> Envelope:
|
|
|
275
276
|
gate = adapter.collectable()
|
|
276
277
|
check("collectable", gate.ok, gate.output, project=name)
|
|
277
278
|
|
|
279
|
+
# R7.13's premise — "files the default runner config cannot reach" — is
|
|
280
|
+
# a config property nothing else enforces. Probe it at preflight so the
|
|
281
|
+
# overlap is named here, not discovered as an opaque mid-cycle failure.
|
|
282
|
+
if project.overrides:
|
|
283
|
+
gate = adapter.override_isolation()
|
|
284
|
+
check(
|
|
285
|
+
"default suite cannot reach override files",
|
|
286
|
+
gate.ok, gate.output, project=name,
|
|
287
|
+
)
|
|
288
|
+
|
|
278
289
|
projects[name] = {"ok": all(c["ok"] for c in checks[before:])}
|
|
279
290
|
|
|
280
291
|
for art in cfg.artifacts.values():
|
|
@@ -812,6 +823,23 @@ def cmd_target(args) -> Envelope:
|
|
|
812
823
|
cycle = ledger.open_cycle(run["id"])
|
|
813
824
|
if cycle is None:
|
|
814
825
|
return failure("no open cycle")
|
|
826
|
+
|
|
827
|
+
# The target must be grounded in observed collection, the same way phase is
|
|
828
|
+
# grounded in observed execution (#15): recording free text deferred a typo —
|
|
829
|
+
# or a speculative `tdd target env` — to the next suite run, where it
|
|
830
|
+
# surfaced as `not_found` against a test that never existed.
|
|
831
|
+
known: set[str] = set()
|
|
832
|
+
for name in json.loads(cycle["projects"]):
|
|
833
|
+
adapter = adapters.build(cfg.project(name), worktree)
|
|
834
|
+
known |= adapter.collect().tests
|
|
835
|
+
if args.test not in known:
|
|
836
|
+
close = difflib.get_close_matches(args.test, sorted(known), n=3, cutoff=0.6)
|
|
837
|
+
hint = f" Closest collected ids: {', '.join(close)}." if close else ""
|
|
838
|
+
return failure(
|
|
839
|
+
f"{args.test} is not a collected test in this cycle's projects;"
|
|
840
|
+
f" the target was not changed.{hint}"
|
|
841
|
+
)
|
|
842
|
+
|
|
815
843
|
ledger.update("cycle", cycle["id"], target_tests=json.dumps([args.test]))
|
|
816
844
|
ledger.event(run["id"], cycle["id"], "target_named_by_agent", args.test)
|
|
817
845
|
return Envelope(
|
|
@@ -88,6 +88,11 @@ class Project:
|
|
|
88
88
|
#: Per-file collection. Must not be parallelised: collection is cheap and xdist
|
|
89
89
|
#: adds startup cost per file.
|
|
90
90
|
collect_command: str | None = None
|
|
91
|
+
#: Environment for the default suite's runs and collection, same semantics as
|
|
92
|
+
#: an override's `env`: `${VAR}` references expand from the environment at
|
|
93
|
+
#: invocation, so per-checkout values (a database port) stay out of the
|
|
94
|
+
#: reviewed file. An override's `env` layers on top for its own suite.
|
|
95
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
91
96
|
#: Alternate suites for files the default command cannot reach (R7.13).
|
|
92
97
|
overrides: list[Override] = field(default_factory=list)
|
|
93
98
|
|
|
@@ -290,6 +295,13 @@ def load(worktree: Path) -> Config:
|
|
|
290
295
|
raise ConfigError(f"project {name!r} has no root")
|
|
291
296
|
if "adapter" not in body:
|
|
292
297
|
raise ConfigError(f"project {name!r} has no adapter")
|
|
298
|
+
env = body.get("env", {})
|
|
299
|
+
if not isinstance(env, dict) or not all(
|
|
300
|
+
isinstance(v, str) for v in env.values()
|
|
301
|
+
):
|
|
302
|
+
raise ConfigError(
|
|
303
|
+
f"project {name!r}: env must be a table of string values"
|
|
304
|
+
)
|
|
293
305
|
projects[name] = Project(
|
|
294
306
|
name=name,
|
|
295
307
|
root=body["root"].rstrip("/"),
|
|
@@ -300,6 +312,7 @@ def load(worktree: Path) -> Config:
|
|
|
300
312
|
in_close_sweep=body.get("in_close_sweep", True),
|
|
301
313
|
test_command=body.get("test_command"),
|
|
302
314
|
collect_command=body.get("collect_command"),
|
|
315
|
+
env=env,
|
|
303
316
|
overrides=_load_overrides(name, body.get("override", [])),
|
|
304
317
|
)
|
|
305
318
|
|
|
@@ -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
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Failure text keeps the tail, where Python puts the error (#17).
|
|
2
|
+
|
|
3
|
+
`target_failure` was truncated to a fixed budget from the head. Deep tracebacks
|
|
4
|
+
(async frameworks, ORMs, HTTP stacks) exceed it easily, delivering frames of
|
|
5
|
+
framework internals and cutting exactly the line that says what went wrong —
|
|
6
|
+
forcing a manual re-run outside tdd to see an error the tool already had.
|
|
7
|
+
Both ends are kept: the head carries the assertion line for plain failures,
|
|
8
|
+
the tail carries the exception for deep ones.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from tddcli import adapters
|
|
17
|
+
from tddcli import config as config_mod
|
|
18
|
+
from tddcli.adapters.base import clip_failure
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_short_text_passes_through_unclipped():
|
|
22
|
+
assert clip_failure("boom", 1500) == "boom"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_long_text_keeps_both_ends_and_marks_the_elision():
|
|
26
|
+
text = "assert first line\n" + ("framework frame\n" * 200) + "ConnectionRefusedError: [Errno 61]"
|
|
27
|
+
clipped = clip_failure(text, 1500)
|
|
28
|
+
assert len(clipped) <= 1500 + 20 # the marker is the only overhead
|
|
29
|
+
assert clipped.startswith("assert first line")
|
|
30
|
+
assert clipped.endswith("ConnectionRefusedError: [Errno 61]")
|
|
31
|
+
assert "…" in clipped
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pytest_adapter(tmp_path):
|
|
35
|
+
(tmp_path / "tdd.toml").write_text(
|
|
36
|
+
"[project.backend]\n"
|
|
37
|
+
'root = "backend"\n'
|
|
38
|
+
'adapter = "pytest"\n'
|
|
39
|
+
'test_paths = ["tests/"]\n'
|
|
40
|
+
'test_command = "pytest tests"\n'
|
|
41
|
+
)
|
|
42
|
+
return adapters.build(config_mod.load(tmp_path).project("backend"), tmp_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_pytest_target_failure_keeps_the_error_at_the_tail(tmp_path, monkeypatch):
|
|
46
|
+
adapter = _pytest_adapter(tmp_path)
|
|
47
|
+
longrepr = ("connector frame\n" * 300) + "ConnectionRefusedError: [Errno 61]"
|
|
48
|
+
|
|
49
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
50
|
+
marker = "--json-report-file="
|
|
51
|
+
path = command.split(marker, 1)[1].split(" --", 1)[0]
|
|
52
|
+
Path(path.strip("'\"")).write_text(json.dumps({
|
|
53
|
+
"tests": [{
|
|
54
|
+
"nodeid": "tests/test_db.py::test_connects",
|
|
55
|
+
"outcome": "failed",
|
|
56
|
+
"call": {"longrepr": longrepr},
|
|
57
|
+
}],
|
|
58
|
+
}))
|
|
59
|
+
return 1, "", ""
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr(adapters.base, "run_command", fake)
|
|
62
|
+
verdict = adapter.run("backend::tests/test_db.py::test_connects")
|
|
63
|
+
assert verdict.target_failure.endswith("ConnectionRefusedError: [Errno 61]")
|
|
64
|
+
assert len(verdict.target_failure) <= 1520
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Per-project `env` for the default suite (#16).
|
|
2
|
+
|
|
3
|
+
Override suites gained an `env` mapping in 0.2.0 because suite commands
|
|
4
|
+
sometimes need environment the invoking shell doesn't have. That need is not
|
|
5
|
+
specific to override suites: a default suite that reads an infrastructure
|
|
6
|
+
endpoint from a variable (a database port varying per worktree being the
|
|
7
|
+
motivating shape) previously had no registry-level way to receive it — the
|
|
8
|
+
workarounds were baking values into `test_command`, teaching the suite's own
|
|
9
|
+
bootstrap to hunt for env files, or exporting variables invisibly in whatever
|
|
10
|
+
shell runs `tdd advance`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest as pytest_framework
|
|
19
|
+
|
|
20
|
+
from tddcli import adapters
|
|
21
|
+
from tddcli import config as config_mod
|
|
22
|
+
from tddcli.config import ConfigError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def project_with(tmp_path: Path, extra: str, adapter: str = "pytest"):
|
|
26
|
+
(tmp_path / "tdd.toml").write_text(
|
|
27
|
+
"[project.backend]\n"
|
|
28
|
+
'root = "backend"\n'
|
|
29
|
+
f'adapter = "{adapter}"\n'
|
|
30
|
+
'test_paths = ["tests/"]\n' + extra
|
|
31
|
+
)
|
|
32
|
+
return config_mod.load(tmp_path).project("backend")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_project_env_is_parsed_from_the_registry(tmp_path):
|
|
36
|
+
project = project_with(
|
|
37
|
+
tmp_path, 'env = { TEST_DB_PORT = "${DB_PORT}" }\n'
|
|
38
|
+
)
|
|
39
|
+
assert project.env == {"TEST_DB_PORT": "${DB_PORT}"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_project_env_must_be_string_valued(tmp_path):
|
|
43
|
+
with pytest_framework.raises(ConfigError, match="env must be a table"):
|
|
44
|
+
project_with(tmp_path, "env = { PORT = 6032 }\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_default_suite_runs_with_the_project_env_expanded(tmp_path, monkeypatch):
|
|
48
|
+
monkeypatch.setenv("DB_PORT", "6032")
|
|
49
|
+
project = project_with(
|
|
50
|
+
tmp_path,
|
|
51
|
+
'test_command = "pytest tests"\n'
|
|
52
|
+
'env = { TEST_DB_PORT = "${DB_PORT}" }\n',
|
|
53
|
+
)
|
|
54
|
+
adapter = adapters.build(project, tmp_path)
|
|
55
|
+
seen: list = []
|
|
56
|
+
|
|
57
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
58
|
+
seen.append((command, extra_env))
|
|
59
|
+
marker = "--json-report-file="
|
|
60
|
+
path = command.split(marker, 1)[1].split(" --", 1)[0]
|
|
61
|
+
Path(path.strip("'\"")).write_text(json.dumps({"tests": []}))
|
|
62
|
+
return 0, "", ""
|
|
63
|
+
|
|
64
|
+
monkeypatch.setattr(adapters.base, "run_command", fake)
|
|
65
|
+
adapter.run(None)
|
|
66
|
+
((_, env),) = seen
|
|
67
|
+
assert env["TEST_DB_PORT"] == "6032"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_override_env_layers_on_top_of_the_project_env(tmp_path, monkeypatch):
|
|
71
|
+
monkeypatch.setenv("DB_PORT", "6032")
|
|
72
|
+
project = project_with(
|
|
73
|
+
tmp_path,
|
|
74
|
+
'test_command = "pytest tests"\n'
|
|
75
|
+
'env = { TEST_DB_PORT = "${DB_PORT}", SHARED = "from-project" }\n'
|
|
76
|
+
"[[project.backend.override]]\n"
|
|
77
|
+
'pattern = "contract/"\n'
|
|
78
|
+
'test_command = "pytest contract"\n'
|
|
79
|
+
'env = { SHARED = "from-override" }\n',
|
|
80
|
+
)
|
|
81
|
+
adapter = adapters.build(project, tmp_path)
|
|
82
|
+
invocations = adapter._suite_invocations()
|
|
83
|
+
assert invocations[0][1] == {"TEST_DB_PORT": "6032", "SHARED": "from-project"}
|
|
84
|
+
assert invocations[1][1] == {"TEST_DB_PORT": "6032", "SHARED": "from-override"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_pytest_collection_of_default_files_carries_the_project_env(
|
|
88
|
+
tmp_path, monkeypatch
|
|
89
|
+
):
|
|
90
|
+
project = project_with(tmp_path, 'env = { TEST_DB_PORT = "6032" }\n')
|
|
91
|
+
(tmp_path / "backend" / "tests").mkdir(parents=True)
|
|
92
|
+
(tmp_path / "backend" / "tests" / "test_a.py").write_text(
|
|
93
|
+
"def test_a(): pass\n"
|
|
94
|
+
)
|
|
95
|
+
adapter = adapters.build(project, tmp_path)
|
|
96
|
+
seen: list = []
|
|
97
|
+
|
|
98
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
99
|
+
seen.append((command, extra_env))
|
|
100
|
+
return 0, "tests/test_a.py::test_a\n", ""
|
|
101
|
+
|
|
102
|
+
monkeypatch.setattr(adapters.pytest_adapter, "run_command", fake)
|
|
103
|
+
adapter.collect()
|
|
104
|
+
assert seen and all(env == {"TEST_DB_PORT": "6032"} for _, env in seen)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_vitest_default_suite_runs_with_the_project_env(tmp_path, monkeypatch):
|
|
108
|
+
project = project_with(
|
|
109
|
+
tmp_path,
|
|
110
|
+
'test_command = "npx vitest run"\n'
|
|
111
|
+
'env = { API_URL = "http://localhost:6032" }\n',
|
|
112
|
+
adapter="vitest",
|
|
113
|
+
)
|
|
114
|
+
adapter = adapters.build(project, tmp_path)
|
|
115
|
+
seen: list = []
|
|
116
|
+
|
|
117
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
118
|
+
seen.append((command, extra_env))
|
|
119
|
+
return 0, json.dumps({"testResults": []}), ""
|
|
120
|
+
|
|
121
|
+
monkeypatch.setattr(adapters.base, "run_command", fake)
|
|
122
|
+
adapter.run(None)
|
|
123
|
+
((_, env),) = seen
|
|
124
|
+
assert env["API_URL"] == "http://localhost:6032"
|
|
@@ -286,12 +286,12 @@ def test_vitest_run_finds_a_target_that_only_the_override_config_reaches(
|
|
|
286
286
|
return 1, json.dumps(report), ""
|
|
287
287
|
|
|
288
288
|
monkeypatch.setattr(adapters.base, "run_command", fake)
|
|
289
|
-
target = "backend::
|
|
289
|
+
target = "backend::contract/api.contract.test.ts > pings the api"
|
|
290
290
|
verdict = adapter.run(target)
|
|
291
291
|
assert verdict.error is None
|
|
292
292
|
assert verdict.target_outcome == "failed"
|
|
293
293
|
assert verdict.target_failure == "nope"
|
|
294
|
-
assert verdict.passed == ["backend::
|
|
294
|
+
assert verdict.passed == ["backend::unit.test.ts > adds"]
|
|
295
295
|
|
|
296
296
|
|
|
297
297
|
def test_vitest_override_without_collect_command_fails_the_collectable_gate(
|
|
@@ -338,7 +338,7 @@ def test_vitest_collection_routes_override_files_to_the_override_command(
|
|
|
338
338
|
monkeypatch.setattr(adapters.vitest_adapter, "run_command", fake)
|
|
339
339
|
collection = adapter.collect()
|
|
340
340
|
assert collection.tests == {
|
|
341
|
-
"backend::
|
|
341
|
+
"backend::contract/api.contract.test.ts > pings the api"
|
|
342
342
|
}
|
|
343
343
|
assert seen[0].startswith("npx vitest list --config vitest.contract.config.ts ")
|
|
344
344
|
|
|
@@ -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::contract/api.test.ts > pings")
|
|
489
|
+
assert verdict.error is not None
|
|
490
|
+
assert "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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""`tdd target` must refuse a name that is not a collected test (#15).
|
|
2
|
+
|
|
3
|
+
The command previously recorded whatever string it was given. A typo — or an
|
|
4
|
+
agent probing the CLI surface with something like `tdd target env` — silently
|
|
5
|
+
*changed the target* to a nonexistent test, deferred the failure to the next
|
|
6
|
+
suite run, and misattributed it as `not_found`, pointing the executor at a test
|
|
7
|
+
that was never supposed to exist. Phase is derived from observed execution;
|
|
8
|
+
the target must be grounded in observed collection the same way.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from conftest import run_cli, write_plan
|
|
14
|
+
|
|
15
|
+
PLAN = """---
|
|
16
|
+
cycles:
|
|
17
|
+
- n: 1
|
|
18
|
+
project: backend
|
|
19
|
+
title: "adding two numbers"
|
|
20
|
+
test: "tests/test_add.py::test_add_two_numbers"
|
|
21
|
+
stub_expected: ["app/calc.py"]
|
|
22
|
+
commit_red: "test: adding two numbers"
|
|
23
|
+
commit_green: "feat: add()"
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Plan
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start(repo):
|
|
31
|
+
plan = write_plan(repo, PLAN)
|
|
32
|
+
assert run_cli(repo, "plan", "register", plan)["ok"]
|
|
33
|
+
started = run_cli(repo, "run", "start", "--plan", plan)
|
|
34
|
+
assert started["ok"], started
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_target_refuses_a_name_that_is_not_a_collected_test(repo):
|
|
38
|
+
start(repo)
|
|
39
|
+
out = run_cli(repo, "target", "env")
|
|
40
|
+
assert out["ok"] is False
|
|
41
|
+
assert "not a collected test" in out["error"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_target_suggests_the_closest_collected_ids_on_a_near_miss(repo):
|
|
45
|
+
start(repo)
|
|
46
|
+
out = run_cli(repo, "target", "backend::tests/test_smoke.py::test_smok")
|
|
47
|
+
assert out["ok"] is False
|
|
48
|
+
assert "backend::tests/test_smoke.py::test_smoke" in out["error"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_target_accepts_a_collected_test(repo):
|
|
52
|
+
start(repo)
|
|
53
|
+
out = run_cli(repo, "target", "backend::tests/test_smoke.py::test_smoke")
|
|
54
|
+
assert out["ok"] is True, out
|
|
55
|
+
assert out["result"]["target"] == "backend::tests/test_smoke.py::test_smoke"
|
|
@@ -56,11 +56,39 @@ def test_parsed_ids_match_the_form_run_produces(tmp_path):
|
|
|
56
56
|
assert from_run in from_list, sorted(from_list)
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def test_ids_are_project_namespaced_and_root_relative(tmp_path):
|
|
60
|
+
"""The path inside the id is project-root-relative, like a pytest nodeid.
|
|
61
|
+
|
|
62
|
+
Found live: ids were worktree-relative (`frontend::frontend/app/...`) while
|
|
63
|
+
`Engine._qualify` strips the root prefix from plan declarations
|
|
64
|
+
(`frontend::app/...`), so a declared vitest target could never match a
|
|
65
|
+
verdict. Standard cycles limped through on R8.9 adoption; pin cycles
|
|
66
|
+
deadlocked in AWAITING_PIN because the pinned test predates the run.
|
|
67
|
+
"""
|
|
60
68
|
adapter = adapter_for(tmp_path)
|
|
61
69
|
path = tmp_path / "frontend" / "contexts" / "__tests__" / "AuthContext.test.tsx"
|
|
62
70
|
one = next(iter(adapter._parse_list_output(LIST_OUTPUT, path)))
|
|
63
|
-
assert one.startswith("frontend::
|
|
71
|
+
assert one.startswith("frontend::contexts/__tests__/AuthContext.test.tsx > ")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_ids_match_what_a_plan_declaration_qualifies_to(tmp_path):
|
|
75
|
+
"""A plan declaring `frontend/contexts/... > name` must hit the adapter's id."""
|
|
76
|
+
from tddcli.contract import DeclaredCycle
|
|
77
|
+
from tddcli.machine import Engine
|
|
78
|
+
|
|
79
|
+
adapter = adapter_for(tmp_path)
|
|
80
|
+
path = tmp_path / "frontend" / "contexts" / "__tests__" / "AuthContext.test.tsx"
|
|
81
|
+
declared = DeclaredCycle(
|
|
82
|
+
ordinal=1,
|
|
83
|
+
kind="pin",
|
|
84
|
+
projects=["frontend"],
|
|
85
|
+
tests=[
|
|
86
|
+
"frontend/contexts/__tests__/AuthContext.test.tsx"
|
|
87
|
+
" > logout clears the stored token"
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
qualified = Engine._qualify(declared, declared.tests[0])
|
|
91
|
+
assert qualified in adapter._parse_list_output(LIST_OUTPUT, path)
|
|
64
92
|
|
|
65
93
|
|
|
66
94
|
def test_arrows_are_not_left_in_the_name(tmp_path):
|
|
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
|