tdd-cli 0.9.0__tar.gz → 0.10.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.9.0 → tdd_cli-0.10.0}/CHANGELOG.md +13 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/PKG-INFO +19 -2
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/README.md +18 -1
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/pyproject.toml +2 -1
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/__init__.py +2 -0
- tdd_cli-0.10.0/src/tddcli/adapters/cargo_adapter.py +321 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/conftest.py +0 -1
- tdd_cli-0.10.0/tests/test_cargo_adapter.py +395 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/.gitignore +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/LICENSE +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/SECURITY.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/plan.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/base.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/exec_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/gradle_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/xctest_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/cli.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/config.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/ledger.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/render.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/target_lint.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_advance_adoption.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_ancillary_files.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_baseline_sanity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_batch_collection.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_concurrent_advance.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_contract.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_doctor_attribution.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_doctor_blockers.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_evidence_extraction.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_exec_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_executor_attribution.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_executor_notes.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_failure_clipping.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_fleet.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_gradle_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_id_normalisation.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_named_leases_and_timeout.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_progress.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_project_env.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_sensitivity_evidence.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_suite_overrides.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_target_lint.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_target_validation.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_timing_visibility.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_undeclared_close_gate.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_undeclared_dedup.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_worker_leases.py +0 -0
- {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_xctest_adapter.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.10.0] - 2026-09-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **cargo adapter** — Rust driver through `cargo test`. Ids are `<target>::<path>`
|
|
14
|
+
(`lib::…` for unit tests, `<tests-file-stem>::…` for integration tests) and compose
|
|
15
|
+
to `--lib`/`--test <name>` + `-- --exact <path>` for targeted runs. Verdicts are
|
|
16
|
+
parsed from cargo's per-test console lines, attributed to targets by the
|
|
17
|
+
`Running …` headers with stderr merged; doc-tests are excluded. A compile error maps
|
|
18
|
+
to `not_collected`, matching gradle/xctest. Collection is `cargo test --tests --
|
|
19
|
+
--list` with a per-file `--test <stem>` fallback; `tdd doctor`'s collectable gate is
|
|
20
|
+
`cargo test --no-run`. Target lint requires the `::` separator.
|
|
21
|
+
|
|
9
22
|
## [0.9.0] - 2026-09-03
|
|
10
23
|
|
|
11
24
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -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
|
|
@@ -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")
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""cargo adapter — pinned against real `cargo test` console output.
|
|
2
|
+
|
|
3
|
+
All tests run without a Rust toolchain: they exercise console parsing, id
|
|
4
|
+
namespacing and command construction against output captured from cargo
|
|
5
|
+
1.98 (2026-09-05), exactly as test_gradle_adapter.py does for Gradle.
|
|
6
|
+
`_run_suite` is mocked.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
from tddcli import config as config_mod
|
|
15
|
+
from tddcli.adapters import available
|
|
16
|
+
from tddcli.adapters.base import FAILED, NOT_COLLECTED, NOT_FOUND, PASSED
|
|
17
|
+
from tddcli.adapters.cargo_adapter import CargoAdapter
|
|
18
|
+
|
|
19
|
+
TOML = """
|
|
20
|
+
[project.kernel]
|
|
21
|
+
root = "kernel"
|
|
22
|
+
adapter = "cargo"
|
|
23
|
+
test_paths = ["tests/"]
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
TOML_WITH_ENV = """
|
|
27
|
+
[project.kernel]
|
|
28
|
+
root = "kernel"
|
|
29
|
+
adapter = "cargo"
|
|
30
|
+
test_paths = ["tests/"]
|
|
31
|
+
env = { CARGO_HOME = "/opt/cargo" }
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
TOML_CUSTOM_CMD = """
|
|
35
|
+
[project.kernel]
|
|
36
|
+
root = "kernel"
|
|
37
|
+
adapter = "cargo"
|
|
38
|
+
test_paths = ["tests/"]
|
|
39
|
+
test_command = "cargo test --tests --features fuzzing"
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# `cargo test --tests 2>&1` — one lib unit test fails, one integration test passes.
|
|
43
|
+
FULL_RUN = """\
|
|
44
|
+
Compiling probe_kernel v0.1.0 (/work/kernel)
|
|
45
|
+
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.49s
|
|
46
|
+
Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-f0a2d29d7c4f02a1)
|
|
47
|
+
|
|
48
|
+
running 3 tests
|
|
49
|
+
test layout::tests::fails_on_purpose ... FAILED
|
|
50
|
+
test tests::version_is_one ... ok
|
|
51
|
+
test layout::tests::wraps_short ... ok
|
|
52
|
+
|
|
53
|
+
failures:
|
|
54
|
+
|
|
55
|
+
---- layout::tests::fails_on_purpose stdout ----
|
|
56
|
+
|
|
57
|
+
thread 'layout::tests::fails_on_purpose' (173172849) panicked at src/lib.rs:7:41:
|
|
58
|
+
assertion `left == right` failed: expected width mismatch
|
|
59
|
+
left: 2
|
|
60
|
+
right: 3
|
|
61
|
+
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
failures:
|
|
65
|
+
layout::tests::fails_on_purpose
|
|
66
|
+
|
|
67
|
+
test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
68
|
+
|
|
69
|
+
error: test failed, to rerun pass `--lib`
|
|
70
|
+
Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-1d2c3b4a5f6e7d8c)
|
|
71
|
+
|
|
72
|
+
running 1 test
|
|
73
|
+
test renders_cover_only ... ok
|
|
74
|
+
|
|
75
|
+
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# Two targets each fail a test at the same bare path (`tests::it_works`) — the
|
|
80
|
+
# panic text must attribute to the target whose header preceded each block,
|
|
81
|
+
# not collide on the shared path.
|
|
82
|
+
COLLIDING_PATHS_RUN = """\
|
|
83
|
+
Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-aaaa)
|
|
84
|
+
|
|
85
|
+
running 1 test
|
|
86
|
+
test tests::it_works ... FAILED
|
|
87
|
+
|
|
88
|
+
failures:
|
|
89
|
+
|
|
90
|
+
---- tests::it_works stdout ----
|
|
91
|
+
|
|
92
|
+
thread 'tests::it_works' panicked at src/lib.rs:3:5:
|
|
93
|
+
lib assertion failed
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
failures:
|
|
97
|
+
tests::it_works
|
|
98
|
+
|
|
99
|
+
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
100
|
+
|
|
101
|
+
error: test failed, to rerun pass `--lib`
|
|
102
|
+
Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-bbbb)
|
|
103
|
+
|
|
104
|
+
running 1 test
|
|
105
|
+
test tests::it_works ... FAILED
|
|
106
|
+
|
|
107
|
+
failures:
|
|
108
|
+
|
|
109
|
+
---- tests::it_works stdout ----
|
|
110
|
+
|
|
111
|
+
thread 'tests::it_works' panicked at tests/roundtrip.rs:5:5:
|
|
112
|
+
roundtrip assertion failed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
failures:
|
|
116
|
+
tests::it_works
|
|
117
|
+
|
|
118
|
+
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
119
|
+
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
# `cargo test --tests -- --list 2>&1`
|
|
123
|
+
LIST_OUTPUT = """\
|
|
124
|
+
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
|
|
125
|
+
Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-f0a2d29d7c4f02a1)
|
|
126
|
+
layout::tests::fails_on_purpose: test
|
|
127
|
+
layout::tests::wraps_short: test
|
|
128
|
+
tests::version_is_one: test
|
|
129
|
+
|
|
130
|
+
3 tests, 0 benchmarks
|
|
131
|
+
Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-1d2c3b4a5f6e7d8c)
|
|
132
|
+
renders_cover_only: test
|
|
133
|
+
|
|
134
|
+
1 test, 0 benchmarks
|
|
135
|
+
Doc-tests probe_kernel
|
|
136
|
+
some_doc_example: test
|
|
137
|
+
|
|
138
|
+
1 test, 0 benchmarks
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# `cargo test --test roundtrip -- --exact renders_cover_only 2>&1`
|
|
142
|
+
TARGETED_PASS = """\
|
|
143
|
+
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
|
|
144
|
+
Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-1d2c3b4a5f6e7d8c)
|
|
145
|
+
|
|
146
|
+
running 1 test
|
|
147
|
+
test renders_cover_only ... ok
|
|
148
|
+
|
|
149
|
+
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
# `cargo test --lib -- --exact layout::tests::nope 2>&1` — filter matched nothing.
|
|
154
|
+
TARGETED_NOT_FOUND = """\
|
|
155
|
+
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
|
|
156
|
+
Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-f0a2d29d7c4f02a1)
|
|
157
|
+
|
|
158
|
+
running 0 tests
|
|
159
|
+
|
|
160
|
+
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
# A type error in the crate: nothing ran.
|
|
165
|
+
COMPILE_ERROR = """\
|
|
166
|
+
Compiling probe_kernel v0.1.0 (/work/kernel)
|
|
167
|
+
error[E0308]: mismatched types
|
|
168
|
+
--> src/lib.rs:12:28
|
|
169
|
+
|
|
|
170
|
+
12 | fn broken() { let x: u32 = "s"; }
|
|
171
|
+
| --- ^^^ expected `u32`, found `&str`
|
|
172
|
+
| |
|
|
173
|
+
| expected due to this
|
|
174
|
+
|
|
175
|
+
For more information about this error, try `rustc --explain E0308`.
|
|
176
|
+
error: could not compile `probe_kernel` (lib test) due to 1 previous error
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
NO_LIB = "error: no library targets found in package `probe_bin`\n"
|
|
180
|
+
|
|
181
|
+
LIB_FAIL = "kernel::lib::layout::tests::fails_on_purpose"
|
|
182
|
+
LIB_PASS = "kernel::lib::layout::tests::wraps_short"
|
|
183
|
+
INT_PASS = "kernel::roundtrip::renders_cover_only"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def make_adapter(tmp_path: Path, toml: str = TOML) -> CargoAdapter:
|
|
187
|
+
(tmp_path / "tdd.toml").write_text(toml)
|
|
188
|
+
(tmp_path / "kernel" / "tests").mkdir(parents=True)
|
|
189
|
+
(tmp_path / "kernel" / "tests" / "roundtrip.rs").write_text("#[test] fn renders_cover_only() {}\n")
|
|
190
|
+
cfg = config_mod.load(tmp_path)
|
|
191
|
+
return CargoAdapter(cfg.project("kernel"), tmp_path)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Registration and ids
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_cargo_is_a_builtin_adapter():
|
|
200
|
+
assert "cargo" in available()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_target_id_lint_requires_target_separator(tmp_path):
|
|
204
|
+
a = make_adapter(tmp_path)
|
|
205
|
+
assert a.lint_target_id("renders_cover_only") is not None
|
|
206
|
+
assert a.lint_target_id("roundtrip::renders_cover_only") is None
|
|
207
|
+
assert a.lint_target_id("lib::layout::tests::wraps_short") is None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_target_path_maps_integration_target_to_its_file(tmp_path):
|
|
211
|
+
a = make_adapter(tmp_path)
|
|
212
|
+
assert a.target_path("roundtrip::renders_cover_only") == "tests/roundtrip.rs"
|
|
213
|
+
assert a.target_path("lib::layout::tests::wraps_short") is None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Command construction
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_targeted_command_scopes_to_the_target_and_uses_exact(tmp_path):
|
|
222
|
+
a = make_adapter(tmp_path)
|
|
223
|
+
assert a._targeted_cmd("roundtrip::renders_cover_only") == (
|
|
224
|
+
"cargo test --test roundtrip -- --exact renders_cover_only"
|
|
225
|
+
)
|
|
226
|
+
assert a._targeted_cmd("lib::layout::tests::wraps_short") == (
|
|
227
|
+
"cargo test --lib -- --exact layout::tests::wraps_short"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_targeted_command_keeps_declared_flags_but_drops_tests_selector(tmp_path):
|
|
232
|
+
a = make_adapter(tmp_path, TOML_CUSTOM_CMD)
|
|
233
|
+
assert a._targeted_cmd("roundtrip::renders_cover_only") == (
|
|
234
|
+
"cargo test --features fuzzing --test roundtrip -- --exact renders_cover_only"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_run_merges_stderr_into_stdout(tmp_path):
|
|
239
|
+
a = make_adapter(tmp_path)
|
|
240
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(0, TARGETED_PASS, "")) as rs:
|
|
241
|
+
a.run(INT_PASS)
|
|
242
|
+
cmd = rs.call_args.args[0]
|
|
243
|
+
assert cmd.endswith("2>&1"), cmd
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Suite run parsing
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_full_run_attributes_tests_to_targets_by_header(tmp_path):
|
|
252
|
+
a = make_adapter(tmp_path)
|
|
253
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, FULL_RUN, "")):
|
|
254
|
+
v = a.run()
|
|
255
|
+
assert v.error is None
|
|
256
|
+
assert set(v.passed) == {LIB_PASS, "kernel::lib::tests::version_is_one", INT_PASS}
|
|
257
|
+
assert v.failed == [LIB_FAIL]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_full_run_does_not_collide_failures_sharing_a_bare_path(tmp_path):
|
|
261
|
+
a = make_adapter(tmp_path)
|
|
262
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, COLLIDING_PATHS_RUN, "")):
|
|
263
|
+
v = a.run()
|
|
264
|
+
assert set(v.failed) == {"kernel::lib::tests::it_works", "kernel::roundtrip::tests::it_works"}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_targeted_run_attributes_the_right_target_when_paths_collide(tmp_path):
|
|
268
|
+
a = make_adapter(tmp_path)
|
|
269
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, COLLIDING_PATHS_RUN, "")):
|
|
270
|
+
lib_v = a.run("kernel::lib::tests::it_works")
|
|
271
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, COLLIDING_PATHS_RUN, "")):
|
|
272
|
+
int_v = a.run("kernel::roundtrip::tests::it_works")
|
|
273
|
+
assert lib_v.target_outcome == FAILED
|
|
274
|
+
assert "lib assertion failed" in lib_v.target_failure
|
|
275
|
+
assert int_v.target_outcome == FAILED
|
|
276
|
+
assert "roundtrip assertion failed" in int_v.target_failure
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_targeted_pass(tmp_path):
|
|
280
|
+
a = make_adapter(tmp_path)
|
|
281
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(0, TARGETED_PASS, "")):
|
|
282
|
+
v = a.run(INT_PASS)
|
|
283
|
+
assert v.target_outcome == PASSED
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_targeted_failure_carries_panic_message_as_evidence(tmp_path):
|
|
287
|
+
a = make_adapter(tmp_path)
|
|
288
|
+
# A targeted lib run prints the same shape as the full run's lib block.
|
|
289
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, FULL_RUN, "")):
|
|
290
|
+
v = a.run(LIB_FAIL)
|
|
291
|
+
assert v.target_outcome == FAILED
|
|
292
|
+
assert "expected width mismatch" in v.target_failure
|
|
293
|
+
assert "panicked at src/lib.rs:7:41" in v.target_evidence
|
|
294
|
+
# The trailing `failures:` summary list is not part of the failure text.
|
|
295
|
+
assert " layout::tests::fails_on_purpose" not in v.target_failure
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_targeted_filter_matching_nothing_is_not_found(tmp_path):
|
|
299
|
+
a = make_adapter(tmp_path)
|
|
300
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(0, TARGETED_NOT_FOUND, "")):
|
|
301
|
+
v = a.run("kernel::lib::layout::tests::nope")
|
|
302
|
+
assert v.target_outcome == NOT_FOUND
|
|
303
|
+
assert v.target_failure == ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_compile_error_is_not_collected_for_a_target(tmp_path):
|
|
307
|
+
a = make_adapter(tmp_path)
|
|
308
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, COMPILE_ERROR, "")):
|
|
309
|
+
v = a.run(LIB_PASS)
|
|
310
|
+
assert v.target_outcome == NOT_COLLECTED
|
|
311
|
+
assert "error[E0308]: mismatched types" in v.target_failure
|
|
312
|
+
assert "src/lib.rs:12:28" in v.target_failure
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_compile_error_is_a_suite_error_for_a_full_run(tmp_path):
|
|
316
|
+
a = make_adapter(tmp_path)
|
|
317
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, COMPILE_ERROR, "")):
|
|
318
|
+
v = a.run()
|
|
319
|
+
assert v.error is not None and "could not compile" in v.error
|
|
320
|
+
assert v.passed == [] and v.failed == []
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_missing_lib_target_is_not_collected(tmp_path):
|
|
324
|
+
a = make_adapter(tmp_path)
|
|
325
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, NO_LIB, "")):
|
|
326
|
+
v = a.run(LIB_PASS)
|
|
327
|
+
assert v.target_outcome == NOT_COLLECTED
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# Collection
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_batch_collection_prefixes_ids_and_skips_doctests(tmp_path):
|
|
336
|
+
a = make_adapter(tmp_path)
|
|
337
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(0, LIST_OUTPUT, "")):
|
|
338
|
+
c = a.collect()
|
|
339
|
+
assert c.tests == {
|
|
340
|
+
LIB_FAIL,
|
|
341
|
+
LIB_PASS,
|
|
342
|
+
"kernel::lib::tests::version_is_one",
|
|
343
|
+
INT_PASS,
|
|
344
|
+
}
|
|
345
|
+
assert c.failed_files == {}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_batch_collection_command_lists_with_merged_streams(tmp_path):
|
|
349
|
+
a = make_adapter(tmp_path)
|
|
350
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(0, LIST_OUTPUT, "")) as rs:
|
|
351
|
+
a.collect()
|
|
352
|
+
assert rs.call_args_list[0].args[0] == "cargo test --tests -- --list 2>&1"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_per_file_collection_records_compile_failure_against_its_file(tmp_path):
|
|
356
|
+
a = make_adapter(tmp_path)
|
|
357
|
+
(tmp_path / "kernel" / "tests" / "broken.rs").write_text("fn nope( {}\n")
|
|
358
|
+
|
|
359
|
+
def side_effect(cmd, env=None, timeout=None):
|
|
360
|
+
if cmd.startswith("cargo test --tests -- --list"):
|
|
361
|
+
return (101, COMPILE_ERROR, "") # batch fails → per-file loop
|
|
362
|
+
if "--test broken" in cmd:
|
|
363
|
+
return (101, COMPILE_ERROR, "")
|
|
364
|
+
if "--test roundtrip" in cmd:
|
|
365
|
+
return (0, "renders_cover_only: test\n\n1 test, 0 benchmarks\n", "")
|
|
366
|
+
raise AssertionError(cmd)
|
|
367
|
+
|
|
368
|
+
with patch.object(CargoAdapter, "_run_suite", side_effect=side_effect):
|
|
369
|
+
c = a.collect()
|
|
370
|
+
assert INT_PASS in c.tests
|
|
371
|
+
assert "tests/broken.rs" in c.failed_files
|
|
372
|
+
assert "could not compile" in c.failed_files["tests/broken.rs"]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_collectable_is_a_no_run_build(tmp_path):
|
|
376
|
+
a = make_adapter(tmp_path)
|
|
377
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(0, "", "")) as rs:
|
|
378
|
+
assert a.collectable().ok
|
|
379
|
+
assert rs.call_args.args[0] == "cargo test --no-run 2>&1"
|
|
380
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(101, COMPILE_ERROR, "")):
|
|
381
|
+
g = a.collectable()
|
|
382
|
+
assert not g.ok and "E0308" in g.output
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_every_invocation_carries_the_project_env(tmp_path):
|
|
386
|
+
"""`env` is how a repo points at a toolchain outside PATH (e.g. an external-disk
|
|
387
|
+
rustup); the doctor gate, collection and runs must all see it."""
|
|
388
|
+
a = make_adapter(tmp_path, TOML_WITH_ENV)
|
|
389
|
+
with patch.object(CargoAdapter, "_run_suite", return_value=(0, LIST_OUTPUT, "")) as rs:
|
|
390
|
+
a.collectable()
|
|
391
|
+
a.collect()
|
|
392
|
+
a.run(INT_PASS)
|
|
393
|
+
for call in rs.call_args_list:
|
|
394
|
+
env = call.args[1] if len(call.args) > 1 else call.kwargs.get("extra_env")
|
|
395
|
+
assert env == {"CARGO_HOME": "/opt/cargo"}, call
|
|
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
|
|
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
|