tdd-cli 0.9.0__tar.gz → 0.10.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.9.0 → tdd_cli-0.10.1}/CHANGELOG.md +25 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/PKG-INFO +19 -2
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/README.md +18 -1
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/pyproject.toml +2 -1
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/__init__.py +2 -0
- tdd_cli-0.10.1/src/tddcli/adapters/cargo_adapter.py +321 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/pytest_adapter.py +21 -3
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/conftest.py +0 -1
- tdd_cli-0.10.1/tests/test_cargo_adapter.py +395 -0
- tdd_cli-0.10.1/tests/test_pytest_xdist_group.py +97 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/.gitignore +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/LICENSE +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/SECURITY.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/plan.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/base.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/exec_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/gradle_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/vitest_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/xctest_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/cli.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/config.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/ledger.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/render.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/target_lint.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_advance_adoption.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_ancillary_files.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_baseline_sanity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_batch_collection.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_concurrent_advance.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_contract.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_doctor_attribution.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_doctor_blockers.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_evidence_extraction.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_exec_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_executor_attribution.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_executor_notes.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_failure_clipping.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_fleet.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_gradle_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_id_normalisation.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_named_leases_and_timeout.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_progress.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_project_env.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_sensitivity_evidence.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_suite_overrides.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_target_lint.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_target_validation.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_timing_visibility.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_undeclared_close_gate.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_undeclared_dedup.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_worker_leases.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_xctest_adapter.py +0 -0
|
@@ -6,6 +6,31 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.1] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **pytest adapter: `--dist loadgroup` node ids.** pytest-xdist reports a test marked
|
|
14
|
+
`xdist_group("<g>")` as `<nodeid>@<g>` and pytest-json-report records that spelling, so a
|
|
15
|
+
grouped target was `not_found` on every suite run even though it was collected and
|
|
16
|
+
passed, and its `passed`/`failed` ids never matched collected ids. The adapter now strips
|
|
17
|
+
the trailing `@<group>` when reading report and `--collect-only` ids. Only an `@` after
|
|
18
|
+
the last `]` is treated as the suffix, so parametrised ids such as
|
|
19
|
+
`test_x[user@example.com]` are left intact.
|
|
20
|
+
|
|
21
|
+
## [0.10.0] - 2026-09-05
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **cargo adapter** — Rust driver through `cargo test`. Ids are `<target>::<path>`
|
|
26
|
+
(`lib::…` for unit tests, `<tests-file-stem>::…` for integration tests) and compose
|
|
27
|
+
to `--lib`/`--test <name>` + `-- --exact <path>` for targeted runs. Verdicts are
|
|
28
|
+
parsed from cargo's per-test console lines, attributed to targets by the
|
|
29
|
+
`Running …` headers with stderr merged; doc-tests are excluded. A compile error maps
|
|
30
|
+
to `not_collected`, matching gradle/xctest. Collection is `cargo test --tests --
|
|
31
|
+
--list` with a per-file `--test <stem>` fallback; `tdd doctor`'s collectable gate is
|
|
32
|
+
`cargo test --no-run`. Target lint requires the `::` separator.
|
|
33
|
+
|
|
9
34
|
## [0.9.0] - 2026-09-03
|
|
10
35
|
|
|
11
36
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -341,7 +341,7 @@ the next plan is written.
|
|
|
341
341
|
|
|
342
342
|
## Adapters
|
|
343
343
|
|
|
344
|
-
`pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
|
|
344
|
+
`pytest`, `vitest`, `gradle`, `xctest`, `cargo`, and `exec` are built in. The pytest adapter runs
|
|
345
345
|
the suite through the project's own environment manager, detected from its marker files —
|
|
346
346
|
`uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
347
347
|
checked at the project root first, then the worktree root (workspace layouts keep one
|
|
@@ -393,6 +393,23 @@ lease = "ios-simulator"
|
|
|
393
393
|
timeout = 900
|
|
394
394
|
```
|
|
395
395
|
|
|
396
|
+
The `cargo` adapter drives Rust crates through `cargo test`. Test ids are
|
|
397
|
+
`<target>::<path>` — `lib::layout::tests::wraps_short` for a `#[cfg(test)]` unit test,
|
|
398
|
+
`roundtrip::renders_cover_only` for `tests/roundtrip.rs` — so a targeted run composes to
|
|
399
|
+
cargo's own selectors (`--lib` / `--test <name>` plus `-- --exact <path>`) with no
|
|
400
|
+
translation. Verdicts come from cargo's per-test console lines, attributed to targets by
|
|
401
|
+
the `Running …` headers (stderr is merged so the order survives); doc-tests are excluded
|
|
402
|
+
(`--tests`). A *compile* failure maps to `not_collected`, as for gradle and xctest: write a
|
|
403
|
+
compiling stub (`todo!()`), then observe the panic. Declare `tests/` as `test_paths`, not
|
|
404
|
+
`src/` — unit-test modules live inside production files and must not be staged as tests.
|
|
405
|
+
|
|
406
|
+
```toml
|
|
407
|
+
[project.kernel]
|
|
408
|
+
root = "kernel"
|
|
409
|
+
adapter = "cargo"
|
|
410
|
+
test_paths = ["tests/"]
|
|
411
|
+
```
|
|
412
|
+
|
|
396
413
|
Third-party adapters register under the
|
|
397
414
|
`tddcli.adapters` entry-point group:
|
|
398
415
|
|
|
@@ -313,7 +313,7 @@ the next plan is written.
|
|
|
313
313
|
|
|
314
314
|
## Adapters
|
|
315
315
|
|
|
316
|
-
`pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
|
|
316
|
+
`pytest`, `vitest`, `gradle`, `xctest`, `cargo`, and `exec` are built in. The pytest adapter runs
|
|
317
317
|
the suite through the project's own environment manager, detected from its marker files —
|
|
318
318
|
`uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
319
319
|
checked at the project root first, then the worktree root (workspace layouts keep one
|
|
@@ -365,6 +365,23 @@ lease = "ios-simulator"
|
|
|
365
365
|
timeout = 900
|
|
366
366
|
```
|
|
367
367
|
|
|
368
|
+
The `cargo` adapter drives Rust crates through `cargo test`. Test ids are
|
|
369
|
+
`<target>::<path>` — `lib::layout::tests::wraps_short` for a `#[cfg(test)]` unit test,
|
|
370
|
+
`roundtrip::renders_cover_only` for `tests/roundtrip.rs` — so a targeted run composes to
|
|
371
|
+
cargo's own selectors (`--lib` / `--test <name>` plus `-- --exact <path>`) with no
|
|
372
|
+
translation. Verdicts come from cargo's per-test console lines, attributed to targets by
|
|
373
|
+
the `Running …` headers (stderr is merged so the order survives); doc-tests are excluded
|
|
374
|
+
(`--tests`). A *compile* failure maps to `not_collected`, as for gradle and xctest: write a
|
|
375
|
+
compiling stub (`todo!()`), then observe the panic. Declare `tests/` as `test_paths`, not
|
|
376
|
+
`src/` — unit-test modules live inside production files and must not be staged as tests.
|
|
377
|
+
|
|
378
|
+
```toml
|
|
379
|
+
[project.kernel]
|
|
380
|
+
root = "kernel"
|
|
381
|
+
adapter = "cargo"
|
|
382
|
+
test_paths = ["tests/"]
|
|
383
|
+
```
|
|
384
|
+
|
|
368
385
|
Third-party adapters register under the
|
|
369
386
|
`tddcli.adapters` entry-point group:
|
|
370
387
|
|
|
@@ -47,10 +47,11 @@ packages = ["src/tddcli"]
|
|
|
47
47
|
include = ["src", "tests", "examples", "README.md", "LICENSE", "CHANGELOG.md", "SECURITY.md"]
|
|
48
48
|
|
|
49
49
|
[dependency-groups]
|
|
50
|
-
dev = ["pytest>=8.0", "pytest-json-report>=1.5", "ruff>=0.5", "zizmor>=1.29"]
|
|
50
|
+
dev = ["pytest>=8.0", "pytest-json-report>=1.5", "pytest-xdist>=3.5", "ruff>=0.5", "zizmor>=1.29"]
|
|
51
51
|
|
|
52
52
|
[tool.pytest.ini_options]
|
|
53
53
|
testpaths = ["tests"]
|
|
54
|
+
addopts = ["-n", "auto"]
|
|
54
55
|
|
|
55
56
|
[tool.ruff]
|
|
56
57
|
line-length = 100
|
|
@@ -4,6 +4,7 @@ import importlib.metadata
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
from .base import Adapter, Collection, GateResult, Verdict
|
|
7
|
+
from .cargo_adapter import CargoAdapter
|
|
7
8
|
from .exec_adapter import ExecAdapter
|
|
8
9
|
from .gradle_adapter import GradleAdapter
|
|
9
10
|
from .pytest_adapter import PytestAdapter
|
|
@@ -11,6 +12,7 @@ from .vitest_adapter import VitestAdapter
|
|
|
11
12
|
from .xctest_adapter import XCTestAdapter
|
|
12
13
|
|
|
13
14
|
REGISTRY: dict[str, type[Adapter]] = {
|
|
15
|
+
"cargo": CargoAdapter,
|
|
14
16
|
"exec": ExecAdapter,
|
|
15
17
|
"gradle": GradleAdapter,
|
|
16
18
|
"pytest": PytestAdapter,
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""cargo adapter — Rust driver through `cargo test` (§10).
|
|
2
|
+
|
|
3
|
+
Test ids are `<target>::<path>`: the *target* is `lib` for unit tests compiled
|
|
4
|
+
into the library (`#[cfg(test)]` modules under `src/`) or the integration-test
|
|
5
|
+
binary name for a file under `tests/` (`tests/roundtrip.rs` → `roundtrip`); the
|
|
6
|
+
*path* is the test's module path exactly as cargo prints it:
|
|
7
|
+
|
|
8
|
+
lib::layout::tests::wraps_short
|
|
9
|
+
roundtrip::renders_cover_only
|
|
10
|
+
|
|
11
|
+
A targeted run composes to cargo's own selectors without translation —
|
|
12
|
+
`cargo test --lib -- --exact layout::tests::wraps_short` or
|
|
13
|
+
`cargo test --test roundtrip -- --exact renders_cover_only` — and nothing else
|
|
14
|
+
is appended.
|
|
15
|
+
|
|
16
|
+
Console output *is* the oracle here: `cargo test` writes no result file, but
|
|
17
|
+
its per-test lines (`test <path> ... ok|FAILED|ignored`) are stable, and each
|
|
18
|
+
target's block is preceded by a `Running unittests src/lib.rs (…)` /
|
|
19
|
+
`Running tests/<name>.rs (…)` header that names the target. cargo prints the
|
|
20
|
+
headers on stderr and the test lines on stdout, so every invocation is run with
|
|
21
|
+
`2>&1` to keep them interleaved in order; attribution is "the most recent
|
|
22
|
+
header". Doc-tests (`Doc-tests <crate>`) have no stable ids and are excluded by
|
|
23
|
+
running `cargo test --tests` by default.
|
|
24
|
+
|
|
25
|
+
A *compile* failure maps to `not_collected`, not `failed`. Rust has no separate
|
|
26
|
+
collection phase — a test naming a missing symbol fails `rustc`, not the run —
|
|
27
|
+
so it is treated like Python's import error and Kotlin's compile error: write a
|
|
28
|
+
compiling stub (a function whose body is `todo!()`), then observe the
|
|
29
|
+
assertion failure. The discriminator is `error: could not compile` (or an
|
|
30
|
+
`error[E…]` diagnostic) with no test line produced.
|
|
31
|
+
|
|
32
|
+
Only files under `test_paths` are tests for staging purposes. Declare
|
|
33
|
+
`tests/`, not `src/`: a `#[cfg(test)]` module lives inside a production file,
|
|
34
|
+
and classifying `src/` as tests would stage implementation in RED commits.
|
|
35
|
+
Cycle targets therefore live in `tests/*.rs`; `lib::` ids are still collected
|
|
36
|
+
and scored so the baseline sees them.
|
|
37
|
+
|
|
38
|
+
Config (`tdd.toml`):
|
|
39
|
+
|
|
40
|
+
[project.kernel]
|
|
41
|
+
root = "transcript-kernel"
|
|
42
|
+
adapter = "cargo"
|
|
43
|
+
test_paths = ["tests/"]
|
|
44
|
+
test_command = "cargo test --tests" # default when omitted
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import re
|
|
50
|
+
import shlex
|
|
51
|
+
import time
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
from .base import (
|
|
55
|
+
FAILED,
|
|
56
|
+
NOT_COLLECTED,
|
|
57
|
+
PASSED,
|
|
58
|
+
Adapter,
|
|
59
|
+
GateResult,
|
|
60
|
+
Verdict,
|
|
61
|
+
clip_failure,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ` Running unittests src/lib.rs (target/debug/deps/x-abc)` → target "lib"
|
|
65
|
+
# ` Running tests/roundtrip.rs (target/debug/deps/roundtrip-abc)` → "roundtrip"
|
|
66
|
+
_RUNNING_RE = re.compile(r"^\s*Running (?:unittests (\S+)|(\S+)) \(", re.MULTILINE)
|
|
67
|
+
_DOCTEST_RE = re.compile(r"^\s*Doc-tests ", re.MULTILINE)
|
|
68
|
+
# `test layout::tests::wraps_short ... ok` / `... FAILED` / `... ignored`
|
|
69
|
+
_TEST_LINE_RE = re.compile(r"^test (\S+) \.\.\. (ok|FAILED|ignored)\s*$", re.MULTILINE)
|
|
70
|
+
# `layout::tests::wraps_short: test` (from `-- --list`)
|
|
71
|
+
_LIST_LINE_RE = re.compile(r"^(\S+): test\s*$", re.MULTILINE)
|
|
72
|
+
# `---- layout::tests::wraps_short stdout ----` opens a failure block
|
|
73
|
+
_FAILURE_BLOCK_RE = re.compile(r"^---- (\S+) stdout ----\s*$", re.MULTILINE)
|
|
74
|
+
_COMPILE_ERROR_RE = re.compile(r"^error(?:\[E\d+\])?: ", re.MULTILINE)
|
|
75
|
+
_COULD_NOT_COMPILE_RE = re.compile(r"could not compile")
|
|
76
|
+
_NO_LIB_RE = re.compile(r"no library targets found")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _target_of_header(match: re.Match) -> str | None:
|
|
80
|
+
unit, path = match.group(1), match.group(2)
|
|
81
|
+
if unit is not None:
|
|
82
|
+
return "lib"
|
|
83
|
+
if path.startswith("tests/") and path.endswith(".rs"):
|
|
84
|
+
return Path(path).stem
|
|
85
|
+
return None # examples/benches: not test targets
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CargoAdapter(Adapter):
|
|
89
|
+
name = "cargo"
|
|
90
|
+
|
|
91
|
+
def stub_hint(self) -> str:
|
|
92
|
+
return (
|
|
93
|
+
"a compiling stub — a function whose body is `todo!()` — so the test"
|
|
94
|
+
" fails on its assertion/panic rather than on `rustc`; a missing"
|
|
95
|
+
" symbol is a *compile* failure (not_collected), not RED"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def lint_target_id(self, native: str) -> str | None:
|
|
99
|
+
if "::" not in native:
|
|
100
|
+
return (
|
|
101
|
+
f"cargo target ids must be '<target>::<path>' (got {native!r}); expected"
|
|
102
|
+
" shape: lib::module::tests::name or <tests-file-stem>::name"
|
|
103
|
+
)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def target_path(self, native: str) -> str | None:
|
|
107
|
+
target, _, _ = native.partition("::")
|
|
108
|
+
if target == "lib":
|
|
109
|
+
return None
|
|
110
|
+
return f"tests/{target}.rs"
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Commands
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _test_cmd(self) -> str:
|
|
117
|
+
return self.project.test_command or "cargo test --tests"
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _selector(native_id: str) -> str:
|
|
121
|
+
target, _, path = native_id.partition("::")
|
|
122
|
+
scope = "--lib" if target == "lib" else f"--test {shlex.quote(target)}"
|
|
123
|
+
return f"{scope} -- --exact {shlex.quote(path)}"
|
|
124
|
+
|
|
125
|
+
def _targeted_cmd(self, native_id: str) -> str:
|
|
126
|
+
"""`cargo test <flags…>` from the declared command, with the suite-wide
|
|
127
|
+
target selector (`--tests`) dropped so the per-target scope can be set."""
|
|
128
|
+
return f"{self._targeted_prefix()} {self._selector(native_id)}"
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Discovery — integration test files
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def _test_files(self) -> list[Path]:
|
|
135
|
+
found: set[Path] = set()
|
|
136
|
+
for pattern in self.project.test_patterns or []:
|
|
137
|
+
if pattern.endswith("/"):
|
|
138
|
+
base = self.root / pattern
|
|
139
|
+
if base.is_dir():
|
|
140
|
+
found.update(p for p in base.glob("*.rs") if p.is_file())
|
|
141
|
+
else:
|
|
142
|
+
found.update(p for p in self.root.glob(pattern) if p.is_file())
|
|
143
|
+
return sorted(found)
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Collection — `cargo test … -- --list`, attributed by target header
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _collect_invocations(self):
|
|
150
|
+
return [(f"{self._test_cmd()} -- --list", self._suite_env(None))]
|
|
151
|
+
|
|
152
|
+
def _collect_batch(self, command, env):
|
|
153
|
+
code, out, _err = self._run_suite(f"{command} 2>&1", env)
|
|
154
|
+
if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
|
|
155
|
+
return None
|
|
156
|
+
tests = self._parse_list(out)
|
|
157
|
+
files = {f"tests/{t.split('::', 1)[0]}.rs" for t in tests if not t.startswith("lib::")}
|
|
158
|
+
return {self.qualify(t) for t in tests}, files
|
|
159
|
+
|
|
160
|
+
def _collect_per_file(self, rels, result):
|
|
161
|
+
for rel in sorted(rels):
|
|
162
|
+
stem = Path(rel).stem
|
|
163
|
+
cmd = f"{self._targeted_prefix()} --test {shlex.quote(stem)} -- --list 2>&1"
|
|
164
|
+
code, out, _err = self._run_suite(cmd, self._suite_env(None))
|
|
165
|
+
if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
|
|
166
|
+
result.failed_files[rel] = clip_failure(self._errors(out))
|
|
167
|
+
continue
|
|
168
|
+
result.tests |= {self.qualify(t) for t in self._parse_list(out, default=stem)}
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
def _targeted_prefix(self) -> str:
|
|
172
|
+
parts = shlex.split(self._test_cmd())
|
|
173
|
+
parts = [p for p in parts if p != "--tests"]
|
|
174
|
+
if "--" in parts:
|
|
175
|
+
parts = parts[: parts.index("--")]
|
|
176
|
+
return shlex.join(parts)
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _parse_list(output: str, default: str | None = None) -> set[str]:
|
|
180
|
+
"""Walk `--list` output, prefixing each `path: test` line with the target
|
|
181
|
+
named by the most recent `Running …` header (or `default` when the
|
|
182
|
+
invocation was already scoped to one target)."""
|
|
183
|
+
tests: set[str] = set()
|
|
184
|
+
target = default
|
|
185
|
+
for line in output.splitlines():
|
|
186
|
+
header = _RUNNING_RE.match(line)
|
|
187
|
+
if header:
|
|
188
|
+
target = _target_of_header(header)
|
|
189
|
+
continue
|
|
190
|
+
if _DOCTEST_RE.match(line):
|
|
191
|
+
target = None
|
|
192
|
+
continue
|
|
193
|
+
entry = _LIST_LINE_RE.match(line)
|
|
194
|
+
if entry and target is not None:
|
|
195
|
+
tests.add(f"{target}::{entry.group(1)}")
|
|
196
|
+
return tests
|
|
197
|
+
|
|
198
|
+
def collectable(self) -> GateResult:
|
|
199
|
+
code, out, err = self._run_suite(
|
|
200
|
+
f"{self._targeted_prefix()} --no-run 2>&1", self._suite_env(None)
|
|
201
|
+
)
|
|
202
|
+
if code == 0:
|
|
203
|
+
return GateResult(ok=True)
|
|
204
|
+
return GateResult(ok=False, output=clip_failure(self._errors(out + err)))
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Results
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _parse_run(output: str, default: str | None = None):
|
|
212
|
+
"""`(passed, failed, failures)` from a merged `cargo test` console."""
|
|
213
|
+
passed: set[str] = set()
|
|
214
|
+
failed: set[str] = set()
|
|
215
|
+
failures: dict[str, str] = {}
|
|
216
|
+
target = default
|
|
217
|
+
# Position of each header, alongside the target it switches to — used to
|
|
218
|
+
# attribute a failure block by the header most recently seen before it,
|
|
219
|
+
# since two targets can share a test path (e.g. both name a test
|
|
220
|
+
# `tests::it_works`) and a path-only lookup would collide between them.
|
|
221
|
+
header_spans: list[tuple[int, str | None]] = []
|
|
222
|
+
pos = 0
|
|
223
|
+
for line in output.splitlines(keepends=True):
|
|
224
|
+
header = _RUNNING_RE.match(line)
|
|
225
|
+
if header:
|
|
226
|
+
target = _target_of_header(header)
|
|
227
|
+
header_spans.append((pos, target))
|
|
228
|
+
elif _DOCTEST_RE.match(line):
|
|
229
|
+
target = None
|
|
230
|
+
header_spans.append((pos, target))
|
|
231
|
+
else:
|
|
232
|
+
entry = _TEST_LINE_RE.match(line)
|
|
233
|
+
if entry and target is not None:
|
|
234
|
+
path, outcome = entry.groups()
|
|
235
|
+
tid = f"{target}::{path}"
|
|
236
|
+
if outcome == "ok":
|
|
237
|
+
passed.add(tid)
|
|
238
|
+
elif outcome == "FAILED":
|
|
239
|
+
failed.add(tid)
|
|
240
|
+
pos += len(line)
|
|
241
|
+
|
|
242
|
+
def _target_at(offset: int) -> str | None:
|
|
243
|
+
current = default
|
|
244
|
+
for start, tgt in header_spans:
|
|
245
|
+
if start > offset:
|
|
246
|
+
break
|
|
247
|
+
current = tgt
|
|
248
|
+
return current
|
|
249
|
+
|
|
250
|
+
# Failure blocks: from `---- <path> stdout ----` up to the next block or
|
|
251
|
+
# the trailing `failures:` summary list.
|
|
252
|
+
blocks = list(_FAILURE_BLOCK_RE.finditer(output))
|
|
253
|
+
for i, m in enumerate(blocks):
|
|
254
|
+
end = blocks[i + 1].start() if i + 1 < len(blocks) else len(output)
|
|
255
|
+
body = output[m.end() : end]
|
|
256
|
+
body = body.split("\nfailures:\n", 1)[0].strip()
|
|
257
|
+
path = m.group(1)
|
|
258
|
+
tid = f"{_target_at(m.start()) or 'lib'}::{path}"
|
|
259
|
+
failures[tid] = clip_failure(body) if body else "test failed"
|
|
260
|
+
return passed, failed, failures
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _errors(combined: str) -> str:
|
|
264
|
+
lines = [
|
|
265
|
+
ln
|
|
266
|
+
for ln in combined.splitlines()
|
|
267
|
+
if _COMPILE_ERROR_RE.match(ln) or "-->" in ln or "could not compile" in ln
|
|
268
|
+
]
|
|
269
|
+
return "\n".join(lines) if lines else combined
|
|
270
|
+
|
|
271
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
272
|
+
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
273
|
+
env = self._suite_env(None)
|
|
274
|
+
|
|
275
|
+
native = self.strip(target) if target is not None else None
|
|
276
|
+
if native is not None:
|
|
277
|
+
cmd = self._targeted_cmd(native)
|
|
278
|
+
default = native.partition("::")[0]
|
|
279
|
+
else:
|
|
280
|
+
cmd = self._test_cmd()
|
|
281
|
+
default = None
|
|
282
|
+
|
|
283
|
+
started = time.monotonic()
|
|
284
|
+
code, out, err = self._run_suite(f"{cmd} 2>&1", env)
|
|
285
|
+
verdict.duration_ms = int((time.monotonic() - started) * 1000)
|
|
286
|
+
combined = out + err
|
|
287
|
+
|
|
288
|
+
passed, failed, failures = self._parse_run(combined, default=default)
|
|
289
|
+
verdict.passed = sorted(self.qualify(t) for t in passed)
|
|
290
|
+
verdict.failed = sorted(self.qualify(t) for t in failed)
|
|
291
|
+
|
|
292
|
+
if not passed and not failed:
|
|
293
|
+
if _COULD_NOT_COMPILE_RE.search(combined) or (
|
|
294
|
+
code != 0 and _COMPILE_ERROR_RE.search(combined)
|
|
295
|
+
):
|
|
296
|
+
errors = clip_failure(self._errors(combined))
|
|
297
|
+
if target is not None:
|
|
298
|
+
verdict.target_outcome = NOT_COLLECTED
|
|
299
|
+
verdict.target_failure = errors
|
|
300
|
+
else:
|
|
301
|
+
verdict.error = errors
|
|
302
|
+
elif target is not None and _NO_LIB_RE.search(combined):
|
|
303
|
+
verdict.target_outcome = NOT_COLLECTED
|
|
304
|
+
verdict.target_failure = clip_failure(combined)
|
|
305
|
+
# else: filter matched nothing → NOT_FOUND; empty full run is clean
|
|
306
|
+
return verdict
|
|
307
|
+
|
|
308
|
+
if target is None or native is None:
|
|
309
|
+
return verdict
|
|
310
|
+
|
|
311
|
+
if target in verdict.passed:
|
|
312
|
+
verdict.target_outcome = PASSED
|
|
313
|
+
elif target in verdict.failed:
|
|
314
|
+
verdict.target_outcome = FAILED
|
|
315
|
+
text = failures.get(native, "test failed")
|
|
316
|
+
verdict.target_failure = text
|
|
317
|
+
verdict.target_evidence = next(
|
|
318
|
+
(ln for ln in text.splitlines() if "panicked" in ln or "assertion" in ln),
|
|
319
|
+
next((ln for ln in text.splitlines() if ln.strip()), ""),
|
|
320
|
+
)
|
|
321
|
+
return verdict
|
|
@@ -43,6 +43,21 @@ RUNNER_MARKERS = (
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def _strip_xdist_group(nodeid: str) -> str:
|
|
47
|
+
"""The plain node id behind xdist's `--dist loadgroup` spelling.
|
|
48
|
+
|
|
49
|
+
A test marked `xdist_group("<g>")` is reported as `<nodeid>@<g>` and pytest-json-report
|
|
50
|
+
records it that way; collected ids and declared targets carry no suffix, so the report
|
|
51
|
+
side is canonicalised here. Only an `@` after the last `]` can be xdist's: function names
|
|
52
|
+
cannot contain one, and parametrised values (`test_x[user@example.com]`) live inside the
|
|
53
|
+
brackets.
|
|
54
|
+
"""
|
|
55
|
+
at = nodeid.rfind("@")
|
|
56
|
+
if at == -1 or at < nodeid.rfind("]"):
|
|
57
|
+
return nodeid
|
|
58
|
+
return nodeid[:at]
|
|
59
|
+
|
|
60
|
+
|
|
46
61
|
class PytestAdapter(Adapter):
|
|
47
62
|
name = "pytest"
|
|
48
63
|
|
|
@@ -121,7 +136,9 @@ class PytestAdapter(Adapter):
|
|
|
121
136
|
verdict.duration_ms += int(report.get("duration", 0) * 1000)
|
|
122
137
|
tests.extend(report.get("tests", []))
|
|
123
138
|
collectors.extend(report.get("collectors", []))
|
|
124
|
-
suite_ids.append(
|
|
139
|
+
suite_ids.append(
|
|
140
|
+
{_strip_xdist_group(t["nodeid"]) for t in report.get("tests", [])}
|
|
141
|
+
)
|
|
125
142
|
|
|
126
143
|
overlap = _suite_overlap(suite_ids)
|
|
127
144
|
if overlap:
|
|
@@ -134,7 +151,7 @@ class PytestAdapter(Adapter):
|
|
|
134
151
|
uncollectable.add(collector.get("nodeid", ""))
|
|
135
152
|
|
|
136
153
|
for test in tests:
|
|
137
|
-
qualified = self.qualify(test["nodeid"])
|
|
154
|
+
qualified = self.qualify(_strip_xdist_group(test["nodeid"]))
|
|
138
155
|
if test["outcome"] == "passed":
|
|
139
156
|
verdict.passed.append(qualified)
|
|
140
157
|
elif test["outcome"] in ("failed", "error"):
|
|
@@ -145,7 +162,7 @@ class PytestAdapter(Adapter):
|
|
|
145
162
|
return verdict
|
|
146
163
|
|
|
147
164
|
native = self.strip(target)
|
|
148
|
-
hit = next((t for t in tests if t["nodeid"] == native), None)
|
|
165
|
+
hit = next((t for t in tests if _strip_xdist_group(t["nodeid"]) == native), None)
|
|
149
166
|
if hit is not None:
|
|
150
167
|
verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
|
|
151
168
|
call = hit.get("call") or hit.get("setup") or {}
|
|
@@ -268,6 +285,7 @@ class PytestAdapter(Adapter):
|
|
|
268
285
|
for line in out.splitlines():
|
|
269
286
|
line = line.strip()
|
|
270
287
|
if "::" in line and not line.startswith(("=", "-", "no tests")):
|
|
288
|
+
line = _strip_xdist_group(line)
|
|
271
289
|
tests.add(self.qualify(line))
|
|
272
290
|
files.add(line.split("::", 1)[0])
|
|
273
291
|
return tests, files
|
|
@@ -64,7 +64,6 @@ def repo(tmp_path, ledger_home):
|
|
|
64
64
|
".pytest_cache/\n__pycache__/\n*.pyc\n.coverage\njsonreport*.json\n"
|
|
65
65
|
)
|
|
66
66
|
|
|
67
|
-
git(root.parent, "init", "-q", str(root)) if False else None
|
|
68
67
|
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
|
69
68
|
git(root, "config", "user.email", "test@example.com")
|
|
70
69
|
git(root, "config", "user.name", "Test")
|