tdd-cli 0.4.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/CHANGELOG.md +54 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/PKG-INFO +1 -1
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/__init__.py +5 -3
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/base.py +68 -4
- tdd_cli-0.5.1/src/tddcli/adapters/exec_adapter.py +173 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/pytest_adapter.py +37 -11
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/vitest_adapter.py +37 -4
- tdd_cli-0.5.1/src/tddcli/adapters/xctest_adapter.py +307 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/cli.py +139 -82
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/config.py +26 -16
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/leases.py +49 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/ledger.py +17 -8
- tdd_cli-0.5.1/tests/test_batch_collection.py +233 -0
- tdd_cli-0.5.1/tests/test_exec_adapter.py +313 -0
- tdd_cli-0.5.1/tests/test_named_leases_and_timeout.py +471 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_suite_overrides.py +7 -1
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_timing_visibility.py +14 -5
- tdd_cli-0.5.1/tests/test_xctest_adapter.py +434 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/.gitignore +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/LICENSE +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/README.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/SECURITY.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/plan.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/pyproject.toml +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/render.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/conftest.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_contract.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_doctor_attribution.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_doctor_blockers.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_failure_clipping.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_fleet.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_progress.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_project_env.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_target_validation.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_worker_leases.py +0 -0
|
@@ -4,6 +4,60 @@ 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
|
+
## [0.5.1] - 2026-08-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **exec adapter** — exit-code oracles as first-class test suites. Any executable
|
|
12
|
+
file (or any file paired with a `test_command`) becomes a test: exit 0 → passed,
|
|
13
|
+
non-zero → failed, stdout+stderr captured as failure output. Non-executable files
|
|
14
|
+
without a `test_command` map to `not_collected` rather than `failed`, so a missing
|
|
15
|
+
executable bit is a configuration error rather than a test failure. Supports a
|
|
16
|
+
`{file}` placeholder in `test_command` for per-file invocation, with shell-quoting
|
|
17
|
+
so paths with spaces are handled correctly (#33).
|
|
18
|
+
|
|
19
|
+
- **xctest adapter** — XCTest/xcodebuild driver for Swift and Objective-C projects.
|
|
20
|
+
Test ids use xcodebuild's own `-only-testing:` format (`Bundle/Class/method`) so
|
|
21
|
+
targeted runs compose without translation. A build failure maps to `not_collected`
|
|
22
|
+
rather than `failed` — Swift has no separate collection phase, so a missing-symbol
|
|
23
|
+
build error is not the RED state; the discipline is to write a compiling stub first,
|
|
24
|
+
then observe an assertion failure. Collection tries `xcodebuild test
|
|
25
|
+
-enumerate-tests` (Xcode 16+) and falls back to grepping Swift source files for
|
|
26
|
+
`class Foo: XCTestCase` and `func testBar()` patterns. Requires `test_command` in
|
|
27
|
+
`tdd.toml` so the adapter can append `-only-testing:` and `-enumerate-tests` flags
|
|
28
|
+
without guessing the scheme or destination (#34).
|
|
29
|
+
|
|
30
|
+
- **Named exclusive leases** (`lease = "<name>"` on a project or override) — before
|
|
31
|
+
running a suite the tool acquires an exclusive machine-wide named lease using the
|
|
32
|
+
same lease-directory machinery as the worker budget. Only one holder machine-wide
|
|
33
|
+
can hold a given name at a time; a waiting invocation blocks with a `lease_waiting`
|
|
34
|
+
heartbeat every 5 s rather than silently hanging. Stale locks (pid dead or older
|
|
35
|
+
than 1 h) are swept immediately so a crash can never permanently block a name.
|
|
36
|
+
The name is free-form and machine-scoped; two repos naming the same lease
|
|
37
|
+
intentionally contend, which is the intended semantics for shared physical hardware
|
|
38
|
+
or a port-bound service (#35).
|
|
39
|
+
|
|
40
|
+
- **Per-project suite timeouts** (`timeout = <seconds>` on a project or override) —
|
|
41
|
+
overrides the 1800 s default for suite invocations. `tdd doctor` warns when a
|
|
42
|
+
configured timeout is shorter than the longest recorded full-suite invocation for
|
|
43
|
+
that project, so a mis-configuration is caught at preflight rather than at the
|
|
44
|
+
timeout boundary (#35).
|
|
45
|
+
|
|
46
|
+
## [0.4.1] - 2026-08-16
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- `collect()` runs one invocation per declared suite instead of one per test
|
|
51
|
+
file, falling back to the per-file loop for anything a batch did not account
|
|
52
|
+
for. Per-file collection was **77% of a real `run start`** — 313 subprocesses
|
|
53
|
+
costing 402s, against 117s to actually run every test — because cost scaled
|
|
54
|
+
with file count at a ~1.08s floor per invocation (the environment manager
|
|
55
|
+
resolving plus the runner booting). Measured 38.8x faster on a 60-file
|
|
56
|
+
project, with an identical collected set. R10.3's guarantee is unchanged: a
|
|
57
|
+
file that fails to collect is still attributed to itself and cannot destroy
|
|
58
|
+
the set, and a file the batch never reports is still collected individually,
|
|
59
|
+
so the set can only match or improve on the old one (#27).
|
|
60
|
+
|
|
7
61
|
## [0.4.0] - 2026-08-16
|
|
8
62
|
|
|
9
63
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Ledger-backed TDD process controller for autonomous coding agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/geuben/tdd-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/geuben/tdd-cli
|
|
@@ -4,12 +4,16 @@ import importlib.metadata
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
from .base import Adapter, Collection, GateResult, Verdict
|
|
7
|
+
from .exec_adapter import ExecAdapter
|
|
7
8
|
from .pytest_adapter import PytestAdapter
|
|
8
9
|
from .vitest_adapter import VitestAdapter
|
|
10
|
+
from .xctest_adapter import XCTestAdapter
|
|
9
11
|
|
|
10
12
|
REGISTRY: dict[str, type[Adapter]] = {
|
|
13
|
+
"exec": ExecAdapter,
|
|
11
14
|
"pytest": PytestAdapter,
|
|
12
15
|
"vitest": VitestAdapter,
|
|
16
|
+
"xctest": XCTestAdapter,
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
|
|
@@ -39,9 +43,7 @@ def build(project, worktree: Path) -> Adapter:
|
|
|
39
43
|
cls = ep.load()
|
|
40
44
|
break
|
|
41
45
|
if cls is None:
|
|
42
|
-
raise RuntimeError(
|
|
43
|
-
f"unknown adapter {project.adapter!r}; available: {sorted(available())}"
|
|
44
|
-
)
|
|
46
|
+
raise RuntimeError(f"unknown adapter {project.adapter!r}; available: {sorted(available())}")
|
|
45
47
|
return cls(project, worktree)
|
|
46
48
|
|
|
47
49
|
|
|
@@ -87,7 +87,9 @@ TIMING_ENV = "TDD_TIMING"
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
def run_command(
|
|
90
|
-
command: str,
|
|
90
|
+
command: str,
|
|
91
|
+
cwd: Path,
|
|
92
|
+
timeout: int = 1800,
|
|
91
93
|
extra_env: dict[str, str] | None = None,
|
|
92
94
|
label: str | None = None,
|
|
93
95
|
) -> tuple[int, str, str]:
|
|
@@ -135,13 +137,16 @@ class Adapter:
|
|
|
135
137
|
|
|
136
138
|
def strip(self, qualified: str) -> str:
|
|
137
139
|
prefix = f"{self.project.name}::"
|
|
138
|
-
return qualified[len(prefix):] if qualified.startswith(prefix) else qualified
|
|
140
|
+
return qualified[len(prefix) :] if qualified.startswith(prefix) else qualified
|
|
139
141
|
|
|
140
142
|
def run(self, target: str | None = None) -> Verdict:
|
|
141
143
|
raise NotImplementedError
|
|
142
144
|
|
|
143
145
|
def _run_suite(
|
|
144
|
-
self,
|
|
146
|
+
self,
|
|
147
|
+
command: str,
|
|
148
|
+
extra_env: dict[str, str] | None = None,
|
|
149
|
+
timeout: int | None = None,
|
|
145
150
|
) -> tuple[int, str, str]:
|
|
146
151
|
"""Run the suite under a machine-wide worker lease.
|
|
147
152
|
|
|
@@ -151,15 +156,32 @@ class Adapter:
|
|
|
151
156
|
prefer to read the budget themselves. The lease is held for the whole
|
|
152
157
|
invocation so concurrent agents in other worktrees see this one and
|
|
153
158
|
take a smaller share.
|
|
159
|
+
|
|
160
|
+
When the project declares `lease`, an exclusive named lease is acquired
|
|
161
|
+
first (outer context), so only one instance of that suite runs machine-
|
|
162
|
+
wide at a time. When `timeout` is given it overrides the project-level
|
|
163
|
+
and default values.
|
|
154
164
|
"""
|
|
155
|
-
|
|
165
|
+
effective_timeout = timeout or self.project.timeout or 1800
|
|
166
|
+
|
|
167
|
+
def _run(workers: int) -> tuple[int, str, str]:
|
|
156
168
|
return run_command(
|
|
157
169
|
command.replace("{workers}", str(workers)),
|
|
158
170
|
self.root,
|
|
171
|
+
timeout=effective_timeout,
|
|
159
172
|
extra_env={"TDD_WORKERS": str(workers), **(extra_env or {})},
|
|
160
173
|
label="suite",
|
|
161
174
|
)
|
|
162
175
|
|
|
176
|
+
lease_name = getattr(self.project, "lease", None)
|
|
177
|
+
if lease_name:
|
|
178
|
+
with leases.named_lease(lease_name):
|
|
179
|
+
with leases.worker_lease() as workers:
|
|
180
|
+
return _run(workers)
|
|
181
|
+
else:
|
|
182
|
+
with leases.worker_lease() as workers:
|
|
183
|
+
return _run(workers)
|
|
184
|
+
|
|
163
185
|
def _test_cmd(self) -> str:
|
|
164
186
|
raise NotImplementedError
|
|
165
187
|
|
|
@@ -189,6 +211,48 @@ class Adapter:
|
|
|
189
211
|
return "a body that fails loudly, never working logic"
|
|
190
212
|
|
|
191
213
|
def collect(self) -> Collection:
|
|
214
|
+
"""Enumerate the project's tests: one invocation per declared suite, with
|
|
215
|
+
the per-file loop kept for what that cannot account for (issue #27).
|
|
216
|
+
|
|
217
|
+
Per file, collection cost scaled with *file count* rather than test count —
|
|
218
|
+
measured at 77% of a whole `run start`, against a floor of ~1.08s per
|
|
219
|
+
invocation that is the environment manager resolving plus the runner
|
|
220
|
+
booting, not collection work.
|
|
221
|
+
|
|
222
|
+
The batch and the loop do not discover the same way: a batch uses the
|
|
223
|
+
runner's own config, the loop walks `test_paths`. So the loop still runs for
|
|
224
|
+
every file the batch did not report — whether the batch failed, returned
|
|
225
|
+
nothing, or simply never mentioned that file. R10.3's guarantee is
|
|
226
|
+
unchanged: one uncollectable file is attributed to itself, and cannot
|
|
227
|
+
destroy the set. What changes is that the healthy case no longer pays for
|
|
228
|
+
the broken one.
|
|
229
|
+
"""
|
|
230
|
+
result = Collection()
|
|
231
|
+
unaccounted = {str(p.relative_to(self.root)) for p in self._test_files()}
|
|
232
|
+
for command, env in self._collect_invocations():
|
|
233
|
+
batch = self._collect_batch(command, env)
|
|
234
|
+
if batch is None:
|
|
235
|
+
continue
|
|
236
|
+
tests, files = batch
|
|
237
|
+
result.tests |= tests
|
|
238
|
+
unaccounted -= files
|
|
239
|
+
return self._collect_per_file(unaccounted, result)
|
|
240
|
+
|
|
241
|
+
def _collect_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
|
|
242
|
+
"""One collection command per declared suite: the default plus each
|
|
243
|
+
override's (R7.13), so an override's files are enumerated with its own
|
|
244
|
+
command and env."""
|
|
245
|
+
raise NotImplementedError
|
|
246
|
+
|
|
247
|
+
def _collect_batch(
|
|
248
|
+
self, command: str, env: dict[str, str] | None
|
|
249
|
+
) -> tuple[set[str], set[str]] | None:
|
|
250
|
+
"""`(test ids, files seen)` for one whole-suite invocation, or None when it
|
|
251
|
+
produced nothing usable — the signal to leave its files to the loop."""
|
|
252
|
+
raise NotImplementedError
|
|
253
|
+
|
|
254
|
+
def _collect_per_file(self, rels: set[str], result: Collection) -> Collection:
|
|
255
|
+
"""R10.3's loop, now reached only for files no batch accounted for."""
|
|
192
256
|
raise NotImplementedError
|
|
193
257
|
|
|
194
258
|
def collectable(self) -> GateResult:
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""exec adapter — exit-code oracles as first-class tests (§10).
|
|
2
|
+
|
|
3
|
+
Each file matching `test_paths` is one test; the verdict is the exit code.
|
|
4
|
+
No output parsing, no runner protocol. Any toolchain can be bootstrapped by
|
|
5
|
+
wrapping its runner in a script before (or instead of) a native adapter
|
|
6
|
+
existing; shell-gate TDD cycles (write a failing check, migrate code until it
|
|
7
|
+
exits 0) use this adapter natively.
|
|
8
|
+
|
|
9
|
+
Collection is a filesystem walk — no subprocess needed. A file that is
|
|
10
|
+
matched by `test_paths` but cannot be run (not executable, no `test_command`)
|
|
11
|
+
maps to `not_collected` rather than `failed`, preserving the "a broken oracle
|
|
12
|
+
is not RED" semantics the other adapters uphold via import errors.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shlex
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .base import (
|
|
23
|
+
FAILED,
|
|
24
|
+
NOT_COLLECTED,
|
|
25
|
+
PASSED,
|
|
26
|
+
Adapter,
|
|
27
|
+
Collection,
|
|
28
|
+
GateResult,
|
|
29
|
+
Verdict,
|
|
30
|
+
clip_failure,
|
|
31
|
+
run_command,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExecAdapter(Adapter):
|
|
36
|
+
name = "exec"
|
|
37
|
+
|
|
38
|
+
def stub_hint(self) -> str:
|
|
39
|
+
return "a script body that exits non-zero (e.g. `exit 1` with a message explaining what is missing)"
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Command resolution
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _cmd_for(self, rel: str) -> str:
|
|
46
|
+
"""The shell command that runs one script.
|
|
47
|
+
|
|
48
|
+
When `test_command` is set it acts as a template: `{file}` is replaced
|
|
49
|
+
by the shell-quoted relative path. Without it the file is invoked
|
|
50
|
+
directly (requires executable bit and a shebang line).
|
|
51
|
+
"""
|
|
52
|
+
if self.project.test_command:
|
|
53
|
+
return self.project.test_command.replace("{file}", shlex.quote(rel))
|
|
54
|
+
return f"./{rel}"
|
|
55
|
+
|
|
56
|
+
def _is_runnable(self, path: Path) -> bool:
|
|
57
|
+
"""True when the file can be executed: either a test_command handles it,
|
|
58
|
+
or the file itself has the executable bit set."""
|
|
59
|
+
if self.project.test_command:
|
|
60
|
+
return True
|
|
61
|
+
return os.access(path, os.X_OK)
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Discovery
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _test_files(self) -> list[Path]:
|
|
68
|
+
found: set[Path] = set()
|
|
69
|
+
for pattern in self.project.test_patterns or []:
|
|
70
|
+
if pattern.endswith("/"):
|
|
71
|
+
base = self.root / pattern
|
|
72
|
+
if base.is_dir():
|
|
73
|
+
for p in base.rglob("*"):
|
|
74
|
+
if p.is_file():
|
|
75
|
+
found.add(p)
|
|
76
|
+
else:
|
|
77
|
+
for p in self.root.glob(pattern):
|
|
78
|
+
if p.is_file():
|
|
79
|
+
found.add(p)
|
|
80
|
+
return sorted(found)
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Collection — filesystem walk, no subprocess
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def collect(self) -> Collection:
|
|
87
|
+
result = Collection()
|
|
88
|
+
for path in self._test_files():
|
|
89
|
+
rel = str(path.relative_to(self.root))
|
|
90
|
+
if self._is_runnable(path):
|
|
91
|
+
result.tests.add(self.qualify(rel))
|
|
92
|
+
else:
|
|
93
|
+
result.failed_files[rel] = (
|
|
94
|
+
f"{rel}: not executable and no test_command configured"
|
|
95
|
+
' (chmod +x, or add test_command = "bash {file}" to tdd.toml)'
|
|
96
|
+
)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
# The base collect() calls these three; exec overrides collect() entirely
|
|
100
|
+
# so they are never reached. Providing trivial bodies avoids surprises if
|
|
101
|
+
# a subclass or test calls them directly.
|
|
102
|
+
def _collect_invocations(self):
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
def _collect_batch(self, command, env):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def _collect_per_file(self, rels, result):
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Doctor gate
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def collectable(self) -> GateResult:
|
|
116
|
+
"""Warn about files that matched test_paths but cannot be run."""
|
|
117
|
+
if self.project.test_command:
|
|
118
|
+
return GateResult(ok=True)
|
|
119
|
+
non_exec = [
|
|
120
|
+
str(p.relative_to(self.root)) for p in self._test_files() if not os.access(p, os.X_OK)
|
|
121
|
+
]
|
|
122
|
+
if not non_exec:
|
|
123
|
+
return GateResult(ok=True)
|
|
124
|
+
listed = "\n".join(non_exec[:10])
|
|
125
|
+
more = f"\n… and {len(non_exec) - 10} more" if len(non_exec) > 10 else ""
|
|
126
|
+
return GateResult(
|
|
127
|
+
ok=False,
|
|
128
|
+
output=(
|
|
129
|
+
"these files matched test_paths but are not executable"
|
|
130
|
+
' (chmod +x, or set test_command = "bash {file}" in tdd.toml):\n' + listed + more
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Suite run
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
139
|
+
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
140
|
+
env = self._suite_env(None)
|
|
141
|
+
started = time.monotonic()
|
|
142
|
+
|
|
143
|
+
for path in self._test_files():
|
|
144
|
+
rel = str(path.relative_to(self.root))
|
|
145
|
+
qualified = self.qualify(rel)
|
|
146
|
+
|
|
147
|
+
# When targeting, skip every other test for speed.
|
|
148
|
+
if target is not None and qualified != target:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if not self._is_runnable(path):
|
|
152
|
+
if target == qualified:
|
|
153
|
+
verdict.target_outcome = NOT_COLLECTED
|
|
154
|
+
verdict.target_failure = f"{rel}: not executable and no test_command configured"
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
code, out, err = run_command(
|
|
158
|
+
self._cmd_for(rel), self.root, extra_env=env, label="suite"
|
|
159
|
+
)
|
|
160
|
+
combined = (out + "\n" + err).strip()
|
|
161
|
+
|
|
162
|
+
if code == 0:
|
|
163
|
+
verdict.passed.append(qualified)
|
|
164
|
+
if target == qualified:
|
|
165
|
+
verdict.target_outcome = PASSED
|
|
166
|
+
else:
|
|
167
|
+
verdict.failed.append(qualified)
|
|
168
|
+
if target == qualified:
|
|
169
|
+
verdict.target_outcome = FAILED
|
|
170
|
+
verdict.target_failure = clip_failure(combined)
|
|
171
|
+
|
|
172
|
+
verdict.duration_ms = int((time.monotonic() - started) * 1000)
|
|
173
|
+
return verdict
|
|
@@ -235,23 +235,49 @@ class PytestAdapter(Adapter):
|
|
|
235
235
|
" cannot reach them (e.g. `pytest tests/`)."
|
|
236
236
|
))
|
|
237
237
|
|
|
238
|
-
def
|
|
238
|
+
def _collect_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
|
|
239
|
+
"""An override without a `collect_command` collects with its `test_command`
|
|
240
|
+
— `--collect-only` composes with any run command."""
|
|
241
|
+
return [(self._collect_cmd(), self._suite_env(None))] + [
|
|
242
|
+
(ov.collect_command or ov.test_command, self._suite_env(ov))
|
|
243
|
+
for ov in self.project.overrides
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
def _nodeids(self, out: str) -> tuple[set[str], set[str]]:
|
|
247
|
+
tests: set[str] = set()
|
|
248
|
+
files: set[str] = set()
|
|
249
|
+
for line in out.splitlines():
|
|
250
|
+
line = line.strip()
|
|
251
|
+
if "::" in line and not line.startswith(("=", "-", "no tests")):
|
|
252
|
+
tests.add(self.qualify(line))
|
|
253
|
+
files.add(line.split("::", 1)[0])
|
|
254
|
+
return tests, files
|
|
255
|
+
|
|
256
|
+
def _collect_batch(
|
|
257
|
+
self, command: str, env: dict[str, str] | None
|
|
258
|
+
) -> tuple[set[str], set[str]] | None:
|
|
259
|
+
code, out, err = run_command(
|
|
260
|
+
f"{command} --collect-only -q", self.root, extra_env=env, label="collect"
|
|
261
|
+
)
|
|
262
|
+
if code != 0:
|
|
263
|
+
return None
|
|
264
|
+
# An empty result needs no special case: it accounts for no files, so every
|
|
265
|
+
# file falls to the loop exactly as a failure would.
|
|
266
|
+
return self._nodeids(out)
|
|
267
|
+
|
|
268
|
+
def _collect_per_file(self, rels: set[str], result: Collection) -> Collection:
|
|
239
269
|
"""Per file (R10.3) — one uncollectable module must not destroy the whole set."""
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
rel = path.relative_to(self.root)
|
|
243
|
-
base, env = self._collect_cmd_for(str(rel))
|
|
270
|
+
for rel in sorted(rels):
|
|
271
|
+
base, env = self._collect_cmd_for(rel)
|
|
244
272
|
code, out, err = run_command(
|
|
245
|
-
f"{base} --collect-only -q {shlex.quote(
|
|
273
|
+
f"{base} --collect-only -q {shlex.quote(rel)}",
|
|
246
274
|
self.root,
|
|
247
275
|
extra_env=env,
|
|
248
276
|
label="collect",
|
|
249
277
|
)
|
|
250
278
|
if code != 0:
|
|
251
|
-
result.failed_files[
|
|
279
|
+
result.failed_files[rel] = (err or out).strip()[:800]
|
|
252
280
|
continue
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if "::" in line and not line.startswith(("=", "-", "no tests")):
|
|
256
|
-
result.tests.add(self.qualify(line))
|
|
281
|
+
tests, _ = self._nodeids(out)
|
|
282
|
+
result.tests |= tests
|
|
257
283
|
return result
|
|
@@ -223,10 +223,43 @@ class VitestAdapter(Adapter):
|
|
|
223
223
|
" config (test.exclude) or scope its include globs."
|
|
224
224
|
))
|
|
225
225
|
|
|
226
|
-
def
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
def _collect_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
|
|
227
|
+
"""An override with no `collect_command` gets no batch — `vitest list` knows
|
|
228
|
+
nothing of its config, and its files fall to the loop, which records the
|
|
229
|
+
missing `collect_command` against each of them as before."""
|
|
230
|
+
return [(self._collect_cmd(), self._suite_env(None))] + [
|
|
231
|
+
(ov.collect_command, self._suite_env(ov))
|
|
232
|
+
for ov in self.project.overrides if ov.collect_command
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
def _collect_batch(
|
|
236
|
+
self, command: str, env: dict[str, str] | None
|
|
237
|
+
) -> tuple[set[str], set[str]] | None:
|
|
238
|
+
"""Unlike `_parse_list_output`, which pins every id to the one file it was
|
|
239
|
+
given, a whole-suite listing must read the file from each line — the same
|
|
240
|
+
`file > describe > name` shape, one file per line rather than one per run."""
|
|
241
|
+
code, out, err = run_command(command, self.root, extra_env=env, label="collect")
|
|
242
|
+
if code != 0:
|
|
243
|
+
return None
|
|
244
|
+
tests: set[str] = set()
|
|
245
|
+
files: set[str] = set()
|
|
246
|
+
for line in out.splitlines():
|
|
247
|
+
line = line.strip()
|
|
248
|
+
if " > " not in line:
|
|
249
|
+
continue
|
|
250
|
+
rel, _, remainder = line.partition(" > ")
|
|
251
|
+
full_name = " ".join(part.strip() for part in remainder.split(" > "))
|
|
252
|
+
if rel and full_name:
|
|
253
|
+
tests.add(self.qualify(f"{rel.strip()} > {full_name}"))
|
|
254
|
+
files.add(rel.strip())
|
|
255
|
+
# An empty result needs no special case: it accounts for no files, so every
|
|
256
|
+
# file falls to the loop exactly as a failure would.
|
|
257
|
+
return tests, files
|
|
258
|
+
|
|
259
|
+
def _collect_per_file(self, rels: set[str], result: Collection) -> Collection:
|
|
260
|
+
for rel_str in sorted(rels):
|
|
261
|
+
path = self.root / rel_str
|
|
262
|
+
rel = Path(rel_str)
|
|
230
263
|
ov = self.project.override_for(str(rel))
|
|
231
264
|
if ov is not None and not ov.collect_command:
|
|
232
265
|
result.failed_files[str(rel)] = (
|