tdd-cli 0.1.0__tar.gz → 0.2.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.1.0 → tdd_cli-0.2.0}/CHANGELOG.md +13 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/PKG-INFO +30 -1
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/README.md +29 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/base.py +26 -2
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/pytest_adapter.py +61 -26
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/vitest_adapter.py +62 -18
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/config.py +84 -1
- tdd_cli-0.2.0/tests/test_suite_overrides.py +406 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/.gitignore +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/LICENSE +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/SECURITY.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/plan.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/pyproject.toml +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/__init__.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/cli.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/ledger.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/render.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/conftest.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_contract.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_doctor_attribution.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_fleet.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_progress.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_worker_leases.py +0 -0
|
@@ -6,6 +6,19 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-08-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Per-pattern suite overrides (`[[project.<name>.override]]` in `tdd.toml`): an
|
|
14
|
+
alternate `test_command` — plus optional `collect_command` and `env` — for
|
|
15
|
+
files the default runner config cannot reach, such as contract tests that need
|
|
16
|
+
a live backend. Collection and suite runs union the default suite with every
|
|
17
|
+
override suite, so a cycle can target such a test without widening the default
|
|
18
|
+
config (which breaks CI and pollutes target adoption with the other suite's
|
|
19
|
+
tests). Override patterns classify their files as tests without being repeated
|
|
20
|
+
in `test_paths`; `env` values may reference `${VAR}`, expanded at invocation.
|
|
21
|
+
|
|
9
22
|
## [0.1.0] - 2026-08-08
|
|
10
23
|
|
|
11
24
|
Initial release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -132,6 +132,35 @@ generated = true # excluded from authorship accounting
|
|
|
132
132
|
A generator that is never hand-edited (`codegen`) is an artifact regeneration command, not a
|
|
133
133
|
project. It has no tests and no cycles.
|
|
134
134
|
|
|
135
|
+
Some tests intentionally live outside the project's default runner config — contract tests
|
|
136
|
+
that need a live backend being the common case, where CI runs the default suite with no
|
|
137
|
+
backend up. Widening the default config to make such a test collectable is the wrong fix:
|
|
138
|
+
the plain suite starts making real network calls, and the other suite's tests pollute
|
|
139
|
+
target adoption. Instead, declare an override per pattern:
|
|
140
|
+
|
|
141
|
+
```toml
|
|
142
|
+
[project.frontend]
|
|
143
|
+
root = "frontend"
|
|
144
|
+
adapter = "vitest"
|
|
145
|
+
test_paths = ["**/*.test.ts"]
|
|
146
|
+
test_command = "npx vitest run"
|
|
147
|
+
|
|
148
|
+
[[project.frontend.override]]
|
|
149
|
+
pattern = "src/__contract__/"
|
|
150
|
+
test_command = "npx vitest run --config vitest.contract.config.ts"
|
|
151
|
+
collect_command = "npx vitest list --config vitest.contract.config.ts"
|
|
152
|
+
env = { API_URL = "http://localhost:${API_PORT}" }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Collection and suite runs union the default suite with every override suite, so a cycle can
|
|
156
|
+
target a test only the alternate config reaches. Patterns match paths relative to the
|
|
157
|
+
project root with `test_paths` semantics, and override files classify as tests without
|
|
158
|
+
being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded from the
|
|
159
|
+
environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
|
|
160
|
+
composes with the run command); a vitest override must declare one — `vitest list` knows
|
|
161
|
+
nothing of the override config, and the mismatch is refused at `tdd run start`, not
|
|
162
|
+
mid-cycle.
|
|
163
|
+
|
|
135
164
|
## Sharing cores between concurrent agents
|
|
136
165
|
|
|
137
166
|
Several agents running tdd-cli on one machine (each in its own worktree) face a bad
|
|
@@ -104,6 +104,35 @@ generated = true # excluded from authorship accounting
|
|
|
104
104
|
A generator that is never hand-edited (`codegen`) is an artifact regeneration command, not a
|
|
105
105
|
project. It has no tests and no cycles.
|
|
106
106
|
|
|
107
|
+
Some tests intentionally live outside the project's default runner config — contract tests
|
|
108
|
+
that need a live backend being the common case, where CI runs the default suite with no
|
|
109
|
+
backend up. Widening the default config to make such a test collectable is the wrong fix:
|
|
110
|
+
the plain suite starts making real network calls, and the other suite's tests pollute
|
|
111
|
+
target adoption. Instead, declare an override per pattern:
|
|
112
|
+
|
|
113
|
+
```toml
|
|
114
|
+
[project.frontend]
|
|
115
|
+
root = "frontend"
|
|
116
|
+
adapter = "vitest"
|
|
117
|
+
test_paths = ["**/*.test.ts"]
|
|
118
|
+
test_command = "npx vitest run"
|
|
119
|
+
|
|
120
|
+
[[project.frontend.override]]
|
|
121
|
+
pattern = "src/__contract__/"
|
|
122
|
+
test_command = "npx vitest run --config vitest.contract.config.ts"
|
|
123
|
+
collect_command = "npx vitest list --config vitest.contract.config.ts"
|
|
124
|
+
env = { API_URL = "http://localhost:${API_PORT}" }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Collection and suite runs union the default suite with every override suite, so a cycle can
|
|
128
|
+
target a test only the alternate config reaches. Patterns match paths relative to the
|
|
129
|
+
project root with `test_paths` semantics, and override files classify as tests without
|
|
130
|
+
being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded from the
|
|
131
|
+
environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
|
|
132
|
+
composes with the run command); a vitest override must declare one — `vitest list` knows
|
|
133
|
+
nothing of the override config, and the mismatch is refused at `tdd run start`, not
|
|
134
|
+
mid-cycle.
|
|
135
|
+
|
|
107
136
|
## Sharing cores between concurrent agents
|
|
108
137
|
|
|
109
138
|
Several agents running tdd-cli on one machine (each in its own worktree) face a bad
|
|
@@ -77,7 +77,9 @@ class Adapter:
|
|
|
77
77
|
def run(self, target: str | None = None) -> Verdict:
|
|
78
78
|
raise NotImplementedError
|
|
79
79
|
|
|
80
|
-
def _run_suite(
|
|
80
|
+
def _run_suite(
|
|
81
|
+
self, command: str, extra_env: dict[str, str] | None = None
|
|
82
|
+
) -> tuple[int, str, str]:
|
|
81
83
|
"""Run the suite under a machine-wide worker lease.
|
|
82
84
|
|
|
83
85
|
Substituting `{workers}` is opt-in per project; a command without the
|
|
@@ -91,9 +93,31 @@ class Adapter:
|
|
|
91
93
|
return run_command(
|
|
92
94
|
command.replace("{workers}", str(workers)),
|
|
93
95
|
self.root,
|
|
94
|
-
extra_env={"TDD_WORKERS": str(workers)},
|
|
96
|
+
extra_env={"TDD_WORKERS": str(workers), **(extra_env or {})},
|
|
95
97
|
)
|
|
96
98
|
|
|
99
|
+
def _test_cmd(self) -> str:
|
|
100
|
+
raise NotImplementedError
|
|
101
|
+
|
|
102
|
+
def _suite_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
|
|
103
|
+
"""The default suite plus one invocation per declared override (R7.13).
|
|
104
|
+
|
|
105
|
+
Runs and collection union these results, so a test reachable only under an
|
|
106
|
+
alternate runner config is still observed — without widening the default
|
|
107
|
+
config, which is exactly the workaround that breaks CI.
|
|
108
|
+
"""
|
|
109
|
+
return [(self._test_cmd(), None)] + [
|
|
110
|
+
(ov.test_command, self._override_env(ov)) for ov in self.project.overrides
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _override_env(override) -> dict[str, str] | None:
|
|
115
|
+
"""`${VAR}` references resolve from the environment at invocation time, so a
|
|
116
|
+
port assigned by the harness need not be hard-coded in the reviewed file."""
|
|
117
|
+
if override is None or not override.env:
|
|
118
|
+
return None
|
|
119
|
+
return {k: os.path.expandvars(v) for k, v in override.env.items()}
|
|
120
|
+
|
|
97
121
|
def stub_hint(self) -> str:
|
|
98
122
|
"""The language idiom for a stub body, quoted into the create_stub directive."""
|
|
99
123
|
return "a body that fails loudly, never working logic"
|
|
@@ -71,8 +71,9 @@ class PytestAdapter(Adapter):
|
|
|
71
71
|
def _collect_cmd(self) -> str:
|
|
72
72
|
return self.project.collect_command or self._base_cmd()
|
|
73
73
|
|
|
74
|
-
def
|
|
75
|
-
|
|
74
|
+
def _suite_report(
|
|
75
|
+
self, base_cmd: str, extra_env: dict[str, str] | None
|
|
76
|
+
) -> tuple[dict | None, str]:
|
|
76
77
|
with tempfile.TemporaryDirectory(prefix="tdd-pytest-") as tmp:
|
|
77
78
|
report_path = Path(tmp) / "report.json"
|
|
78
79
|
# Only reporting flags are appended — parallelism, markers and plugins
|
|
@@ -80,26 +81,40 @@ class PytestAdapter(Adapter):
|
|
|
80
81
|
# `collectors` is omitted when nothing fails to collect, and present with
|
|
81
82
|
# the failing entry when something does, which is when it is consulted.
|
|
82
83
|
cmd = (
|
|
83
|
-
f"{
|
|
84
|
+
f"{base_cmd} --json-report"
|
|
84
85
|
f" --json-report-file={shlex.quote(str(report_path))}"
|
|
85
86
|
)
|
|
86
|
-
code, out, err = self._run_suite(cmd)
|
|
87
|
+
code, out, err = self._run_suite(cmd, extra_env)
|
|
87
88
|
if not report_path.is_file():
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
+ (err or out)[:500]
|
|
89
|
+
return None, (
|
|
90
|
+
f"`{base_cmd}` produced no JSON report"
|
|
91
|
+
" (is pytest-json-report installed?): " + (err or out)[:500]
|
|
91
92
|
)
|
|
92
|
-
|
|
93
|
-
report = json.loads(report_path.read_text())
|
|
93
|
+
return json.loads(report_path.read_text()), ""
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
96
|
+
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
97
|
+
# Union across the default suite and every override suite (R7.13). A suite
|
|
98
|
+
# that produces no report is a loud error, not a silent gap: swallowing it
|
|
99
|
+
# would report a target that lives in that suite as `not_found`, sending the
|
|
100
|
+
# agent to rewrite a test that is fine.
|
|
101
|
+
tests: list[dict] = []
|
|
102
|
+
collectors: list[dict] = []
|
|
103
|
+
for base_cmd, extra_env in self._suite_invocations():
|
|
104
|
+
report, error = self._suite_report(base_cmd, extra_env)
|
|
105
|
+
if report is None:
|
|
106
|
+
verdict.error = error
|
|
107
|
+
return verdict
|
|
108
|
+
verdict.duration_ms += int(report.get("duration", 0) * 1000)
|
|
109
|
+
tests.extend(report.get("tests", []))
|
|
110
|
+
collectors.extend(report.get("collectors", []))
|
|
96
111
|
|
|
97
112
|
uncollectable: set[str] = set()
|
|
98
|
-
for collector in
|
|
113
|
+
for collector in collectors:
|
|
99
114
|
if collector.get("outcome") not in (None, "passed"):
|
|
100
115
|
uncollectable.add(collector.get("nodeid", ""))
|
|
101
116
|
|
|
102
|
-
for test in
|
|
117
|
+
for test in tests:
|
|
103
118
|
qualified = self.qualify(test["nodeid"])
|
|
104
119
|
if test["outcome"] == "passed":
|
|
105
120
|
verdict.passed.append(qualified)
|
|
@@ -111,9 +126,7 @@ class PytestAdapter(Adapter):
|
|
|
111
126
|
return verdict
|
|
112
127
|
|
|
113
128
|
native = self.strip(target)
|
|
114
|
-
hit = next(
|
|
115
|
-
(t for t in report.get("tests", []) if t["nodeid"] == native), None
|
|
116
|
-
)
|
|
129
|
+
hit = next((t for t in tests if t["nodeid"] == native), None)
|
|
117
130
|
if hit is not None:
|
|
118
131
|
verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
|
|
119
132
|
call = hit.get("call") or hit.get("setup") or {}
|
|
@@ -122,21 +135,30 @@ class PytestAdapter(Adapter):
|
|
|
122
135
|
target_file = native.split("::", 1)[0]
|
|
123
136
|
if any(c == target_file or c.startswith(target_file) for c in uncollectable):
|
|
124
137
|
verdict.target_outcome = NOT_COLLECTED
|
|
125
|
-
verdict.target_failure = self._collector_error(
|
|
138
|
+
verdict.target_failure = self._collector_error(collectors, target_file)[:1500]
|
|
126
139
|
else:
|
|
127
140
|
verdict.target_outcome = NOT_FOUND
|
|
128
141
|
return verdict
|
|
129
142
|
|
|
130
143
|
@staticmethod
|
|
131
|
-
def _collector_error(
|
|
132
|
-
for collector in
|
|
144
|
+
def _collector_error(collectors: list[dict], target_file: str) -> str:
|
|
145
|
+
for collector in collectors:
|
|
133
146
|
if collector.get("nodeid", "").startswith(target_file):
|
|
134
147
|
return str(collector.get("longrepr", ""))
|
|
135
148
|
return ""
|
|
136
149
|
|
|
150
|
+
def _collect_cmd_for(self, rel: str) -> tuple[str, dict[str, str] | None]:
|
|
151
|
+
"""The collection command for one file: the owning override's, else the
|
|
152
|
+
project default. An override without a `collect_command` collects with its
|
|
153
|
+
`test_command` — pytest's `--collect-only` composes with any run command."""
|
|
154
|
+
ov = self.project.override_for(rel)
|
|
155
|
+
if ov is None:
|
|
156
|
+
return self._collect_cmd(), None
|
|
157
|
+
return ov.collect_command or ov.test_command, self._override_env(ov)
|
|
158
|
+
|
|
137
159
|
def _test_files(self) -> list[Path]:
|
|
138
160
|
found: list[Path] = []
|
|
139
|
-
for pattern in self.project.
|
|
161
|
+
for pattern in self.project.test_patterns or ["tests/"]:
|
|
140
162
|
if pattern.endswith("/"):
|
|
141
163
|
base = self.root / pattern
|
|
142
164
|
if base.is_dir():
|
|
@@ -147,27 +169,40 @@ class PytestAdapter(Adapter):
|
|
|
147
169
|
return sorted({p for p in found if p.is_file()})
|
|
148
170
|
|
|
149
171
|
def collectable(self) -> GateResult:
|
|
150
|
-
"""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
"""One whole-suite `--collect-only` per declared suite (§10) — the default
|
|
173
|
+
command plus each override's — not the per-file `collect()` loop below.
|
|
174
|
+
That loop is R10.3/R10.4's per-file collection, the slow path (the
|
|
175
|
+
whole-suite probe costs 0.04s on a broken project vs. minutes for the
|
|
176
|
+
per-file sweep on a real one).
|
|
154
177
|
|
|
155
178
|
Reads **stdout**, not stderr: `uv` writes environment warnings
|
|
156
179
|
(`VIRTUAL_ENV=... does not match ...`) to stderr while pytest writes the
|
|
157
180
|
actual `ModuleNotFoundError` to stdout. A doctor check that reads stderr
|
|
158
181
|
loses the real error and the failure surfaces unattributed.
|
|
159
182
|
"""
|
|
160
|
-
|
|
161
|
-
|
|
183
|
+
chunks = []
|
|
184
|
+
probes = [(self._collect_cmd(), None)] + [
|
|
185
|
+
(ov.collect_command or ov.test_command, self._override_env(ov))
|
|
186
|
+
for ov in self.project.overrides
|
|
187
|
+
]
|
|
188
|
+
for cmd, env in probes:
|
|
189
|
+
code, out, err = run_command(
|
|
190
|
+
f"{cmd} --collect-only -q", self.root, extra_env=env
|
|
191
|
+
)
|
|
192
|
+
if code != 0:
|
|
193
|
+
chunks.append(out.strip())
|
|
194
|
+
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
162
195
|
|
|
163
196
|
def collect(self) -> Collection:
|
|
164
197
|
"""Per file (R10.3) — one uncollectable module must not destroy the whole set."""
|
|
165
198
|
result = Collection()
|
|
166
199
|
for path in self._test_files():
|
|
167
200
|
rel = path.relative_to(self.root)
|
|
201
|
+
base, env = self._collect_cmd_for(str(rel))
|
|
168
202
|
code, out, err = run_command(
|
|
169
|
-
f"{
|
|
203
|
+
f"{base} --collect-only -q {shlex.quote(str(rel))}",
|
|
170
204
|
self.root,
|
|
205
|
+
extra_env=env,
|
|
171
206
|
)
|
|
172
207
|
if code != 0:
|
|
173
208
|
result.failed_files[str(rel)] = (err or out).strip()[:800]
|
|
@@ -49,19 +49,32 @@ class VitestAdapter(Adapter):
|
|
|
49
49
|
rel = suite_path
|
|
50
50
|
return self.qualify(f"{rel} > {full_name}")
|
|
51
51
|
|
|
52
|
+
def _test_cmd(self) -> str:
|
|
53
|
+
return self.project.test_command or "npx vitest run"
|
|
54
|
+
|
|
55
|
+
def _collect_cmd(self) -> str:
|
|
56
|
+
return self.project.collect_command or "npx vitest list"
|
|
57
|
+
|
|
52
58
|
def run(self, target: str | None = None) -> Verdict:
|
|
53
59
|
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
report
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
# Union across the default suite and every override suite (R7.13). A suite
|
|
61
|
+
# producing no JSON is a loud error, not a silent gap: swallowing it would
|
|
62
|
+
# report a target living in that suite as `not_found`.
|
|
63
|
+
suites: list[dict] = []
|
|
64
|
+
for base, extra_env in self._suite_invocations():
|
|
65
|
+
code, out, err = self._run_suite(f"{base} --reporter=json", extra_env)
|
|
66
|
+
report = _extract_json(out)
|
|
67
|
+
if report is None:
|
|
68
|
+
verdict.error = (
|
|
69
|
+
f"`{base}` produced no JSON output: {(err or out)[:500]}"
|
|
70
|
+
)
|
|
71
|
+
return verdict
|
|
72
|
+
verdict.duration_ms += int(report.get("duration") or 0)
|
|
73
|
+
suites.extend(report.get("testResults", []))
|
|
60
74
|
|
|
61
|
-
verdict.duration_ms = int(report.get("duration") or 0)
|
|
62
75
|
failed_suites: dict[str, str] = {}
|
|
63
76
|
|
|
64
|
-
for suite in
|
|
77
|
+
for suite in suites:
|
|
65
78
|
suite_path = suite.get("name", "")
|
|
66
79
|
assertions = suite.get("assertionResults", [])
|
|
67
80
|
if not assertions and suite.get("status") == "failed":
|
|
@@ -82,7 +95,7 @@ class VitestAdapter(Adapter):
|
|
|
82
95
|
return verdict
|
|
83
96
|
if target in verdict.failed:
|
|
84
97
|
verdict.target_outcome = FAILED
|
|
85
|
-
for suite in
|
|
98
|
+
for suite in suites:
|
|
86
99
|
for t in suite.get("assertionResults", []):
|
|
87
100
|
if self._id_for(suite.get("name", ""), t["fullName"]) == target:
|
|
88
101
|
verdict.target_failure = "\n".join(
|
|
@@ -101,7 +114,7 @@ class VitestAdapter(Adapter):
|
|
|
101
114
|
|
|
102
115
|
def _test_files(self) -> list[Path]:
|
|
103
116
|
found: set[Path] = set()
|
|
104
|
-
for pattern in self.project.
|
|
117
|
+
for pattern in self.project.test_patterns or ["**/*.test.ts"]:
|
|
105
118
|
pat = pattern.rstrip("/") + "/**/*" if pattern.endswith("/") else pattern
|
|
106
119
|
for path in self.root.glob(pat):
|
|
107
120
|
if path.is_file() and path.suffix in (".ts", ".tsx", ".js", ".jsx"):
|
|
@@ -134,19 +147,50 @@ class VitestAdapter(Adapter):
|
|
|
134
147
|
return found
|
|
135
148
|
|
|
136
149
|
def collectable(self) -> GateResult:
|
|
137
|
-
"""
|
|
138
|
-
(§10): `npx vitest list` at the project root rather than
|
|
139
|
-
per-file `collect()` loop below.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
"""One whole-suite probe per declared suite, mirroring the pytest adapter's
|
|
151
|
+
`--collect-only` (§10): `npx vitest list` at the project root rather than
|
|
152
|
+
the per-file `collect()` loop below.
|
|
153
|
+
|
|
154
|
+
An override without a `collect_command` fails here, at run start, not
|
|
155
|
+
per-file during a cycle: `vitest list` knows nothing of the override's
|
|
156
|
+
config, and falling back to the override's *run* command would execute the
|
|
157
|
+
suite — against a live backend — just to enumerate it.
|
|
158
|
+
"""
|
|
159
|
+
chunks = []
|
|
160
|
+
code, out, err = run_command(self._collect_cmd(), self.root)
|
|
161
|
+
if code != 0:
|
|
162
|
+
chunks.append((err or out).strip())
|
|
163
|
+
for ov in self.project.overrides:
|
|
164
|
+
if not ov.collect_command:
|
|
165
|
+
chunks.append(
|
|
166
|
+
f"override {ov.pattern!r}: a vitest override needs an explicit"
|
|
167
|
+
' collect_command (e.g. "npx vitest list --config'
|
|
168
|
+
' vitest.other.config.ts")'
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
code, out, err = run_command(
|
|
172
|
+
ov.collect_command, self.root, extra_env=self._override_env(ov)
|
|
173
|
+
)
|
|
174
|
+
if code != 0:
|
|
175
|
+
chunks.append((err or out).strip())
|
|
176
|
+
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
143
177
|
|
|
144
178
|
def collect(self) -> Collection:
|
|
145
179
|
result = Collection()
|
|
146
180
|
for path in self._test_files():
|
|
147
181
|
rel = path.relative_to(self.root)
|
|
148
|
-
|
|
149
|
-
|
|
182
|
+
ov = self.project.override_for(str(rel))
|
|
183
|
+
if ov is not None and not ov.collect_command:
|
|
184
|
+
result.failed_files[str(rel)] = (
|
|
185
|
+
f"override {ov.pattern!r} declares no collect_command; vitest"
|
|
186
|
+
" cannot list these tests under the default config"
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
base = ov.collect_command if ov else self._collect_cmd()
|
|
190
|
+
env = self._override_env(ov)
|
|
191
|
+
code, out, err = run_command(
|
|
192
|
+
f"{base} {shlex.quote(str(rel))}", self.root, extra_env=env
|
|
193
|
+
)
|
|
150
194
|
|
|
151
195
|
payload = _extract_json(out)
|
|
152
196
|
if payload is not None:
|
|
@@ -42,6 +42,35 @@ class ConfigError(RuntimeError):
|
|
|
42
42
|
pass
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _pattern_matches(rel_path: str, pattern: str) -> bool:
|
|
46
|
+
"""`test_paths`-style matching: trailing-slash directory, glob, or bare
|
|
47
|
+
directory prefix."""
|
|
48
|
+
if pattern.endswith("/"):
|
|
49
|
+
return rel_path.startswith(pattern)
|
|
50
|
+
return fnmatch(rel_path, pattern) or rel_path.startswith(pattern.rstrip("/") + "/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Override:
|
|
55
|
+
"""An alternate suite for the files matching `pattern` (R7.13).
|
|
56
|
+
|
|
57
|
+
Some tests intentionally live outside the project's default runner config —
|
|
58
|
+
contract tests that need a live backend being the motivating case. Widening the
|
|
59
|
+
default config to satisfy collection breaks CI (the default suite suddenly makes
|
|
60
|
+
real network calls) and pollutes target adoption with tests from the other suite.
|
|
61
|
+
Instead the registry declares the alternate command; collection and suite runs
|
|
62
|
+
union the default suite with every override suite.
|
|
63
|
+
|
|
64
|
+
`pattern` matches paths relative to the project root, like `test_paths`.
|
|
65
|
+
`env` values may reference `${VAR}`, expanded from the environment at invocation.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
pattern: str
|
|
69
|
+
test_command: str
|
|
70
|
+
collect_command: str | None = None
|
|
71
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
45
74
|
@dataclass
|
|
46
75
|
class Project:
|
|
47
76
|
name: str
|
|
@@ -59,6 +88,26 @@ class Project:
|
|
|
59
88
|
#: Per-file collection. Must not be parallelised: collection is cheap and xdist
|
|
60
89
|
#: adds startup cost per file.
|
|
61
90
|
collect_command: str | None = None
|
|
91
|
+
#: Alternate suites for files the default command cannot reach (R7.13).
|
|
92
|
+
overrides: list[Override] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
def override_for(self, rel_path: str) -> Override | None:
|
|
95
|
+
"""First declared override whose pattern matches (path relative to the
|
|
96
|
+
project root), so precedence is the reviewed file's order, never dict or
|
|
97
|
+
filesystem order. Patterns follow `test_paths` semantics: a glob, a
|
|
98
|
+
trailing-slash directory, or a bare directory prefix."""
|
|
99
|
+
for ov in self.overrides:
|
|
100
|
+
if _pattern_matches(rel_path, ov.pattern):
|
|
101
|
+
return ov
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def test_patterns(self) -> list[str]:
|
|
106
|
+
"""`test_paths` plus every override pattern. An override's files are tests by
|
|
107
|
+
declaration — the pattern exists to name the command that runs them — so they
|
|
108
|
+
classify as tests for staging and discovery without being repeated in
|
|
109
|
+
`test_paths`."""
|
|
110
|
+
return self.test_paths + [ov.pattern for ov in self.overrides]
|
|
62
111
|
|
|
63
112
|
def owns(self, rel_path: str) -> bool:
|
|
64
113
|
if self.root == ".": # single-project repo: the root is the worktree itself
|
|
@@ -74,7 +123,7 @@ class Project:
|
|
|
74
123
|
if not self.owns(rel_path):
|
|
75
124
|
return False
|
|
76
125
|
inner = self.relative_to_root(rel_path)
|
|
77
|
-
for pattern in self.
|
|
126
|
+
for pattern in self.test_patterns:
|
|
78
127
|
if pattern.endswith("/"):
|
|
79
128
|
if inner.startswith(pattern):
|
|
80
129
|
return True
|
|
@@ -196,6 +245,39 @@ def find_config(start: Path) -> Path | None:
|
|
|
196
245
|
return None
|
|
197
246
|
|
|
198
247
|
|
|
248
|
+
def _load_overrides(project: str, raw: list) -> list[Override]:
|
|
249
|
+
overrides: list[Override] = []
|
|
250
|
+
for i, body in enumerate(raw, start=1):
|
|
251
|
+
if not isinstance(body, dict):
|
|
252
|
+
raise ConfigError(
|
|
253
|
+
f"project {project!r} override #{i} must be a table"
|
|
254
|
+
" ([[project.<name>.override]])"
|
|
255
|
+
)
|
|
256
|
+
if "pattern" not in body:
|
|
257
|
+
raise ConfigError(f"project {project!r} override #{i} has no pattern")
|
|
258
|
+
if "test_command" not in body:
|
|
259
|
+
raise ConfigError(
|
|
260
|
+
f"project {project!r} override {body['pattern']!r} has no test_command"
|
|
261
|
+
)
|
|
262
|
+
env = body.get("env", {})
|
|
263
|
+
if not isinstance(env, dict) or not all(
|
|
264
|
+
isinstance(v, str) for v in env.values()
|
|
265
|
+
):
|
|
266
|
+
raise ConfigError(
|
|
267
|
+
f"project {project!r} override {body['pattern']!r}: env must be a"
|
|
268
|
+
" table of string values"
|
|
269
|
+
)
|
|
270
|
+
overrides.append(
|
|
271
|
+
Override(
|
|
272
|
+
pattern=body["pattern"],
|
|
273
|
+
test_command=body["test_command"],
|
|
274
|
+
collect_command=body.get("collect_command"),
|
|
275
|
+
env=env,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return overrides
|
|
279
|
+
|
|
280
|
+
|
|
199
281
|
def load(worktree: Path) -> Config:
|
|
200
282
|
path = worktree / CONFIG_NAME
|
|
201
283
|
if not path.is_file():
|
|
@@ -218,6 +300,7 @@ def load(worktree: Path) -> Config:
|
|
|
218
300
|
in_close_sweep=body.get("in_close_sweep", True),
|
|
219
301
|
test_command=body.get("test_command"),
|
|
220
302
|
collect_command=body.get("collect_command"),
|
|
303
|
+
overrides=_load_overrides(name, body.get("override", [])),
|
|
221
304
|
)
|
|
222
305
|
|
|
223
306
|
artifacts: dict[str, Artifact] = {}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Per-pattern suite overrides (R7.13).
|
|
2
|
+
|
|
3
|
+
Some tests intentionally live outside the project's default runner config —
|
|
4
|
+
contract tests that need a live backend being the motivating case. Before
|
|
5
|
+
overrides existed, the only way to make such a test collectable was to widen the
|
|
6
|
+
default config, which broke CI (the plain suite suddenly made real network
|
|
7
|
+
calls) and polluted target adoption with the other suite's tests. The registry
|
|
8
|
+
now declares the alternate command per pattern, and collection and runs union
|
|
9
|
+
the default suite with every override suite.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest as pytest_framework
|
|
18
|
+
|
|
19
|
+
from conftest import git, run_cli, write_plan
|
|
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
|
+
OVERRIDE_BLOCK = (
|
|
36
|
+
"[[project.backend.override]]\n"
|
|
37
|
+
'pattern = "contract/"\n'
|
|
38
|
+
'test_command = "pytest contract"\n'
|
|
39
|
+
'collect_command = "pytest contract -p no:cacheprovider"\n'
|
|
40
|
+
"env = { API_URL = \"http://localhost:${TDD_TEST_PORT}\" }\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# -- registry ------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_override_is_parsed_from_the_registry(tmp_path):
|
|
48
|
+
project = project_with(tmp_path, OVERRIDE_BLOCK)
|
|
49
|
+
(ov,) = project.overrides
|
|
50
|
+
assert ov.pattern == "contract/"
|
|
51
|
+
assert ov.test_command == "pytest contract"
|
|
52
|
+
assert ov.collect_command == "pytest contract -p no:cacheprovider"
|
|
53
|
+
assert ov.env == {"API_URL": "http://localhost:${TDD_TEST_PORT}"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_override_without_pattern_or_command_is_refused(tmp_path):
|
|
57
|
+
with pytest_framework.raises(ConfigError, match="has no pattern"):
|
|
58
|
+
project_with(
|
|
59
|
+
tmp_path, '[[project.backend.override]]\ntest_command = "pytest x"\n'
|
|
60
|
+
)
|
|
61
|
+
with pytest_framework.raises(ConfigError, match="has no test_command"):
|
|
62
|
+
project_with(tmp_path, '[[project.backend.override]]\npattern = "x/"\n')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_override_env_must_be_string_valued(tmp_path):
|
|
66
|
+
with pytest_framework.raises(ConfigError, match="env must be a table"):
|
|
67
|
+
project_with(
|
|
68
|
+
tmp_path,
|
|
69
|
+
"[[project.backend.override]]\n"
|
|
70
|
+
'pattern = "x/"\n'
|
|
71
|
+
'test_command = "pytest x"\n'
|
|
72
|
+
"env = { PORT = 9600 }\n",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_override_for_matches_like_test_paths_first_declared_wins(tmp_path):
|
|
77
|
+
project = project_with(
|
|
78
|
+
tmp_path,
|
|
79
|
+
"[[project.backend.override]]\n"
|
|
80
|
+
'pattern = "contract/smoke/**"\n'
|
|
81
|
+
'test_command = "pytest contract/smoke"\n'
|
|
82
|
+
"[[project.backend.override]]\n"
|
|
83
|
+
'pattern = "contract/"\n'
|
|
84
|
+
'test_command = "pytest contract"\n'
|
|
85
|
+
"[[project.backend.override]]\n"
|
|
86
|
+
'pattern = "legacy"\n'
|
|
87
|
+
'test_command = "pytest legacy"\n',
|
|
88
|
+
)
|
|
89
|
+
assert project.override_for("contract/smoke/test_a.py").test_command == (
|
|
90
|
+
"pytest contract/smoke"
|
|
91
|
+
)
|
|
92
|
+
assert project.override_for("contract/test_b.py").test_command == "pytest contract"
|
|
93
|
+
assert project.override_for("legacy/test_old.py").test_command == "pytest legacy"
|
|
94
|
+
assert project.override_for("tests/test_c.py") is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_override_files_classify_as_tests_without_repeating_test_paths(tmp_path):
|
|
98
|
+
"""Staging classification must see override files as tests: otherwise a RED
|
|
99
|
+
commit containing only the new contract test is flagged as implementation
|
|
100
|
+
written during RED."""
|
|
101
|
+
project = project_with(tmp_path, OVERRIDE_BLOCK)
|
|
102
|
+
assert project.is_test_file("backend/contract/test_api.py")
|
|
103
|
+
assert not project.is_test_file("backend/app/api.py")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# -- pytest adapter ------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fake_pytest_run(reports_by_prefix: dict[str, dict], seen: list):
|
|
110
|
+
"""A run_command double that answers each suite command with its own report,
|
|
111
|
+
keyed by command prefix, writing the JSON where the real plugin would."""
|
|
112
|
+
|
|
113
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
114
|
+
seen.append((command, extra_env))
|
|
115
|
+
for prefix, report in reports_by_prefix.items():
|
|
116
|
+
if command.startswith(prefix):
|
|
117
|
+
marker = "--json-report-file="
|
|
118
|
+
if marker in command:
|
|
119
|
+
path = command.split(marker, 1)[1].split(" --", 1)[0]
|
|
120
|
+
Path(path.strip("'\"")).write_text(json.dumps(report))
|
|
121
|
+
return 1 if report.get("tests") else 0, "", ""
|
|
122
|
+
raise AssertionError(f"unexpected command: {command}")
|
|
123
|
+
|
|
124
|
+
return fake
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_pytest_run_unions_default_and_override_suites(tmp_path, monkeypatch):
|
|
128
|
+
project = project_with(
|
|
129
|
+
tmp_path,
|
|
130
|
+
'test_command = "pytest tests"\n' + OVERRIDE_BLOCK,
|
|
131
|
+
)
|
|
132
|
+
adapter = adapters.build(project, tmp_path)
|
|
133
|
+
seen: list = []
|
|
134
|
+
monkeypatch.setattr(
|
|
135
|
+
adapters.base,
|
|
136
|
+
"run_command",
|
|
137
|
+
_fake_pytest_run(
|
|
138
|
+
{
|
|
139
|
+
"pytest tests": {
|
|
140
|
+
"duration": 1.0,
|
|
141
|
+
"tests": [{"nodeid": "tests/test_a.py::test_a", "outcome": "passed"}],
|
|
142
|
+
},
|
|
143
|
+
"pytest contract": {
|
|
144
|
+
"duration": 2.0,
|
|
145
|
+
"tests": [
|
|
146
|
+
{
|
|
147
|
+
"nodeid": "contract/test_api.py::test_ping",
|
|
148
|
+
"outcome": "failed",
|
|
149
|
+
"call": {"longrepr": "boom"},
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
seen,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
verdict = adapter.run("backend::contract/test_api.py::test_ping")
|
|
158
|
+
assert verdict.error is None
|
|
159
|
+
assert verdict.target_outcome == "failed"
|
|
160
|
+
assert verdict.target_failure == "boom"
|
|
161
|
+
assert verdict.passed == ["backend::tests/test_a.py::test_a"]
|
|
162
|
+
assert verdict.failed == ["backend::contract/test_api.py::test_ping"]
|
|
163
|
+
assert verdict.duration_ms == 3000
|
|
164
|
+
assert [c.split(" --json-report", 1)[0] for c, _ in seen] == [
|
|
165
|
+
"pytest tests",
|
|
166
|
+
"pytest contract",
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_pytest_override_env_reaches_the_suite_with_vars_expanded(
|
|
171
|
+
tmp_path, monkeypatch
|
|
172
|
+
):
|
|
173
|
+
monkeypatch.setenv("TDD_TEST_PORT", "9600")
|
|
174
|
+
project = project_with(tmp_path, 'test_command = "pytest tests"\n' + OVERRIDE_BLOCK)
|
|
175
|
+
adapter = adapters.build(project, tmp_path)
|
|
176
|
+
seen: list = []
|
|
177
|
+
monkeypatch.setattr(
|
|
178
|
+
adapters.base,
|
|
179
|
+
"run_command",
|
|
180
|
+
_fake_pytest_run(
|
|
181
|
+
{"pytest tests": {"tests": []}, "pytest contract": {"tests": []}}, seen
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
adapter.run(None)
|
|
185
|
+
(_, default_env), (_, override_env) = seen
|
|
186
|
+
assert "API_URL" not in default_env
|
|
187
|
+
assert override_env["API_URL"] == "http://localhost:9600"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_pytest_broken_override_suite_is_a_loud_error_not_a_silent_gap(
|
|
191
|
+
tmp_path, monkeypatch
|
|
192
|
+
):
|
|
193
|
+
"""Swallowing a report-less override run would resolve a target living in that
|
|
194
|
+
suite as `not_found`, sending the agent to rewrite a perfectly good test."""
|
|
195
|
+
project = project_with(tmp_path, 'test_command = "pytest tests"\n' + OVERRIDE_BLOCK)
|
|
196
|
+
adapter = adapters.build(project, tmp_path)
|
|
197
|
+
|
|
198
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
199
|
+
if command.startswith("pytest contract"):
|
|
200
|
+
return 4, "", "ERROR: file or directory not found: contract"
|
|
201
|
+
marker = "--json-report-file="
|
|
202
|
+
path = command.split(marker, 1)[1].split(" --", 1)[0]
|
|
203
|
+
Path(path.strip("'\"")).write_text(json.dumps({"tests": []}))
|
|
204
|
+
return 0, "", ""
|
|
205
|
+
|
|
206
|
+
monkeypatch.setattr(adapters.base, "run_command", fake)
|
|
207
|
+
verdict = adapter.run("backend::contract/test_api.py::test_ping")
|
|
208
|
+
assert verdict.error is not None
|
|
209
|
+
assert "pytest contract" in verdict.error
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_pytest_collection_routes_override_files_to_the_override_command(
|
|
213
|
+
tmp_path, monkeypatch
|
|
214
|
+
):
|
|
215
|
+
project = project_with(tmp_path, OVERRIDE_BLOCK)
|
|
216
|
+
(tmp_path / "backend" / "tests").mkdir(parents=True)
|
|
217
|
+
(tmp_path / "backend" / "contract").mkdir()
|
|
218
|
+
(tmp_path / "backend" / "tests" / "test_a.py").write_text("def test_a(): pass\n")
|
|
219
|
+
(tmp_path / "backend" / "contract" / "test_api.py").write_text(
|
|
220
|
+
"def test_ping(): pass\n"
|
|
221
|
+
)
|
|
222
|
+
adapter = adapters.build(project, tmp_path)
|
|
223
|
+
seen: list = []
|
|
224
|
+
|
|
225
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
226
|
+
seen.append((command, extra_env))
|
|
227
|
+
name = "test_api.py::test_ping" if "contract" in command else "test_a.py::test_a"
|
|
228
|
+
return 0, name, ""
|
|
229
|
+
|
|
230
|
+
monkeypatch.setattr(adapters.pytest_adapter, "run_command", fake)
|
|
231
|
+
collection = adapter.collect()
|
|
232
|
+
assert collection.tests == {
|
|
233
|
+
"backend::test_a.py::test_a",
|
|
234
|
+
"backend::test_api.py::test_ping",
|
|
235
|
+
}
|
|
236
|
+
contract_calls = [c for c, _ in seen if "contract/test_api.py" in c]
|
|
237
|
+
assert contract_calls and contract_calls[0].startswith(
|
|
238
|
+
"pytest contract -p no:cacheprovider --collect-only -q"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# -- vitest adapter ------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
VITEST_OVERRIDE = (
|
|
246
|
+
'test_command = "npx vitest run"\n'
|
|
247
|
+
"[[project.backend.override]]\n"
|
|
248
|
+
'pattern = "contract/"\n'
|
|
249
|
+
'test_command = "npx vitest run --config vitest.contract.config.ts"\n'
|
|
250
|
+
'collect_command = "npx vitest list --config vitest.contract.config.ts"\n'
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _vitest_report(file_path: str, full_name: str, status: str) -> dict:
|
|
255
|
+
return {
|
|
256
|
+
"duration": 5,
|
|
257
|
+
"testResults": [
|
|
258
|
+
{
|
|
259
|
+
"name": file_path,
|
|
260
|
+
"status": status,
|
|
261
|
+
"assertionResults": [
|
|
262
|
+
{"fullName": full_name, "status": status, "failureMessages": ["nope"]}
|
|
263
|
+
],
|
|
264
|
+
}
|
|
265
|
+
],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_vitest_run_finds_a_target_that_only_the_override_config_reaches(
|
|
270
|
+
tmp_path, monkeypatch
|
|
271
|
+
):
|
|
272
|
+
project = project_with(tmp_path, VITEST_OVERRIDE, adapter="vitest")
|
|
273
|
+
adapter = adapters.build(project, tmp_path)
|
|
274
|
+
|
|
275
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
276
|
+
if "--config vitest.contract.config.ts" in command:
|
|
277
|
+
report = _vitest_report(
|
|
278
|
+
str(tmp_path / "backend" / "contract" / "api.contract.test.ts"),
|
|
279
|
+
"pings the api",
|
|
280
|
+
"failed",
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
report = _vitest_report(
|
|
284
|
+
str(tmp_path / "backend" / "unit.test.ts"), "adds", "passed"
|
|
285
|
+
)
|
|
286
|
+
return 1, json.dumps(report), ""
|
|
287
|
+
|
|
288
|
+
monkeypatch.setattr(adapters.base, "run_command", fake)
|
|
289
|
+
target = "backend::backend/contract/api.contract.test.ts > pings the api"
|
|
290
|
+
verdict = adapter.run(target)
|
|
291
|
+
assert verdict.error is None
|
|
292
|
+
assert verdict.target_outcome == "failed"
|
|
293
|
+
assert verdict.target_failure == "nope"
|
|
294
|
+
assert verdict.passed == ["backend::backend/unit.test.ts > adds"]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_vitest_override_without_collect_command_fails_the_collectable_gate(
|
|
298
|
+
tmp_path, monkeypatch
|
|
299
|
+
):
|
|
300
|
+
"""Fail at run start, not per-file mid-cycle: `vitest list` knows nothing of
|
|
301
|
+
the override config, and falling back to the override's *run* command would
|
|
302
|
+
execute the suite — against a live backend — just to enumerate it."""
|
|
303
|
+
project = project_with(
|
|
304
|
+
tmp_path,
|
|
305
|
+
"[[project.backend.override]]\n"
|
|
306
|
+
'pattern = "contract/"\n'
|
|
307
|
+
'test_command = "npx vitest run --config vitest.contract.config.ts"\n',
|
|
308
|
+
adapter="vitest",
|
|
309
|
+
)
|
|
310
|
+
adapter = adapters.build(project, tmp_path)
|
|
311
|
+
monkeypatch.setattr(
|
|
312
|
+
adapters.vitest_adapter, "run_command", lambda *a, **k: (0, "", "")
|
|
313
|
+
)
|
|
314
|
+
gate = adapter.collectable()
|
|
315
|
+
assert not gate.ok
|
|
316
|
+
assert "collect_command" in gate.output
|
|
317
|
+
|
|
318
|
+
(tmp_path / "backend" / "contract").mkdir(parents=True)
|
|
319
|
+
(tmp_path / "backend" / "contract" / "api.contract.test.ts").write_text("")
|
|
320
|
+
collection = adapter.collect()
|
|
321
|
+
assert "contract/api.contract.test.ts" in collection.failed_files
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_vitest_collection_routes_override_files_to_the_override_command(
|
|
325
|
+
tmp_path, monkeypatch
|
|
326
|
+
):
|
|
327
|
+
project = project_with(tmp_path, VITEST_OVERRIDE, adapter="vitest")
|
|
328
|
+
(tmp_path / "backend" / "contract").mkdir(parents=True)
|
|
329
|
+
contract_file = tmp_path / "backend" / "contract" / "api.contract.test.ts"
|
|
330
|
+
contract_file.write_text("")
|
|
331
|
+
adapter = adapters.build(project, tmp_path)
|
|
332
|
+
seen: list = []
|
|
333
|
+
|
|
334
|
+
def fake(command, cwd, timeout=1800, extra_env=None):
|
|
335
|
+
seen.append(command)
|
|
336
|
+
return 0, "contract/api.contract.test.ts > pings the api", ""
|
|
337
|
+
|
|
338
|
+
monkeypatch.setattr(adapters.vitest_adapter, "run_command", fake)
|
|
339
|
+
collection = adapter.collect()
|
|
340
|
+
assert collection.tests == {
|
|
341
|
+
"backend::backend/contract/api.contract.test.ts > pings the api"
|
|
342
|
+
}
|
|
343
|
+
assert seen[0].startswith("npx vitest list --config vitest.contract.config.ts ")
|
|
344
|
+
|
|
345
|
+
# With every suite listable the gate passes: the probe must not manufacture a
|
|
346
|
+
# failure out of a healthy override.
|
|
347
|
+
assert adapter.collectable().ok
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# -- end to end ----------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
PLAN = """---
|
|
354
|
+
cycles:
|
|
355
|
+
- n: 1
|
|
356
|
+
project: backend
|
|
357
|
+
test: "contract/test_api.py::test_add_via_api"
|
|
358
|
+
---
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_a_cycle_can_target_a_test_only_an_override_suite_reaches(repo):
|
|
363
|
+
"""The full RED → GREEN path for a target the default command never runs.
|
|
364
|
+
|
|
365
|
+
The default suite is pinned to `pytest tests`, so nothing under `contract/`
|
|
366
|
+
is reachable by it; before overrides this cycle could not reach RED at all.
|
|
367
|
+
"""
|
|
368
|
+
(repo / "backend" / "contract").mkdir()
|
|
369
|
+
(repo / "backend" / "contract" / "test_ping.py").write_text(
|
|
370
|
+
"def test_ping():\n assert True\n"
|
|
371
|
+
)
|
|
372
|
+
(repo / "backend" / "app" / "calc.py").write_text(
|
|
373
|
+
"def add(a, b):\n raise NotImplementedError\n"
|
|
374
|
+
)
|
|
375
|
+
(repo / "tdd.toml").write_text(
|
|
376
|
+
"[project.backend]\n"
|
|
377
|
+
'root = "backend"\n'
|
|
378
|
+
'adapter = "pytest"\n'
|
|
379
|
+
'test_paths = ["tests/"]\n'
|
|
380
|
+
'test_command = "pytest tests"\n'
|
|
381
|
+
"lint = []\n"
|
|
382
|
+
"typecheck = []\n"
|
|
383
|
+
"[[project.backend.override]]\n"
|
|
384
|
+
'pattern = "contract/"\n'
|
|
385
|
+
'test_command = "pytest contract"\n'
|
|
386
|
+
)
|
|
387
|
+
git(repo, "add", "-A")
|
|
388
|
+
git(repo, "commit", "-q", "-m", "declare the contract-suite override")
|
|
389
|
+
|
|
390
|
+
plan = write_plan(repo, PLAN)
|
|
391
|
+
run_cli(repo, "plan", "register", plan)
|
|
392
|
+
start = run_cli(repo, "run", "start", "--plan", plan)
|
|
393
|
+
assert start["ok"], start
|
|
394
|
+
|
|
395
|
+
(repo / "backend" / "contract" / "test_api.py").write_text(
|
|
396
|
+
"from app.calc import add\n\n\n"
|
|
397
|
+
"def test_add_via_api():\n assert add(2, 2) == 4\n"
|
|
398
|
+
)
|
|
399
|
+
red = run_cli(repo, "advance")
|
|
400
|
+
assert red["next_action"]["verb"] == "write_implementation", red
|
|
401
|
+
|
|
402
|
+
(repo / "backend" / "app" / "calc.py").write_text(
|
|
403
|
+
"def add(a, b):\n return a + b\n"
|
|
404
|
+
)
|
|
405
|
+
green = run_cli(repo, "advance")
|
|
406
|
+
assert green["next_action"]["verb"] == "refactor_or_advance", green
|
|
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
|