tdd-cli 0.5.1__tar.gz → 0.7.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.5.1 → tdd_cli-0.7.0}/CHANGELOG.md +46 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/PKG-INFO +89 -9
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/README.md +88 -8
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/__init__.py +2 -0
- tdd_cli-0.7.0/src/tddcli/adapters/gradle_adapter.py +327 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/xctest_adapter.py +11 -11
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/advance.py +10 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/cli.py +87 -17
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/config.py +29 -7
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/ledger.py +63 -20
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/machine.py +20 -5
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/conftest.py +88 -1
- tdd_cli-0.7.0/tests/test_baseline_integrity.py +321 -0
- tdd_cli-0.7.0/tests/test_concurrent_advance.py +178 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_config_and_staging.py +103 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_doctor_attribution.py +74 -5
- tdd_cli-0.7.0/tests/test_gradle_adapter.py +453 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_heartbeat.py +20 -1
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_xctest_adapter.py +40 -49
- tdd_cli-0.5.1/tests/test_baseline_integrity.py +0 -148
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/.gitignore +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/LICENSE +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/SECURITY.md +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/plan.md +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/pyproject.toml +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/base.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/exec_adapter.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/render.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_batch_collection.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_contract.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_doctor_blockers.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_exec_adapter.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_failure_clipping.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_fleet.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_named_leases_and_timeout.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_progress.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_project_env.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_suite_overrides.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_target_validation.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_timing_visibility.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_worker_leases.py +0 -0
|
@@ -4,6 +4,52 @@ 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
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.7.0] - 2026-08-23
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Concurrent `tdd advance` no longer corrupts a run.** Two `advance` processes
|
|
14
|
+
racing on the same worktree could both close the same cycle row. Because
|
|
15
|
+
`close_cycle` unconditionally opened the next ordinal, a double-close forked the
|
|
16
|
+
run into two parallel cycle chains; every remaining cycle ran twice, the run
|
|
17
|
+
"completed" with one chain's last row permanently open, and the doubling was
|
|
18
|
+
invisible from the agent's perspective. `close_cycle` now re-reads `closed_at`
|
|
19
|
+
before acting; if the row is already closed it returns the currently-open cycle
|
|
20
|
+
without transitioning or opening anything. `open_cycle` returns the existing open
|
|
21
|
+
row for an ordinal rather than inserting a duplicate.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Per-worktree advance claim.** `tdd advance` now acquires a `advance_claim` row
|
|
26
|
+
before dispatching. A second concurrent `advance` is refused immediately with
|
|
27
|
+
`ok: false`, `reason: "advance_in_flight"`, and metadata (`pid`, `started_at`,
|
|
28
|
+
`elapsed_s`) that lets the agent confirm the holder is still alive. The claim is
|
|
29
|
+
released in a `finally` so a raising handler cannot wedge the worktree; a claim
|
|
30
|
+
held by a dead pid is reclaimed automatically on the next call. Schema version
|
|
31
|
+
bumped to 3.
|
|
32
|
+
|
|
33
|
+
## [0.6.0] - 2026-08-19
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **gradle adapter** — Gradle driver for Kotlin/JVM and Android projects. Runs the
|
|
38
|
+
project's Gradle test task (`./gradlew test`, `testDebugUnitTest`, or
|
|
39
|
+
`connectedDebugAndroidTest`) and reads per-test verdicts from the JUnit XML Gradle
|
|
40
|
+
writes, rather than scraping the console — unit and instrumented tasks share one
|
|
41
|
+
parser, so the task is a config choice, not a code path. A compile failure maps to
|
|
42
|
+
`not_collected` rather than `failed`: Kotlin has no separate collection phase, so a
|
|
43
|
+
test referencing a missing symbol fails the build, and the "stub before RED"
|
|
44
|
+
discipline holds exactly as it does for a Python import error. The discriminator is
|
|
45
|
+
whether fresh JUnit XML was produced, not "BUILD FAILED" (a test failure prints that
|
|
46
|
+
too); a `--tests` filter matching nothing maps to `not_found`. Stale results are
|
|
47
|
+
cleared before each run, which also invalidates Gradle's up-to-date check so a
|
|
48
|
+
targeted run is scored only against the XML it produced. Collection is a per-file
|
|
49
|
+
grep of Kotlin/Java sources (Gradle has no cheap whole-suite enumerator), so one
|
|
50
|
+
unreadable file cannot destroy the set. `tdd doctor` gains a `gradle wrapper present`
|
|
51
|
+
check, active only when the command uses `./gradlew` (#41).
|
|
52
|
+
|
|
7
53
|
## [0.5.1] - 2026-08-17
|
|
8
54
|
|
|
9
55
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -290,12 +290,57 @@ the next plan is written.
|
|
|
290
290
|
|
|
291
291
|
## Adapters
|
|
292
292
|
|
|
293
|
-
`pytest` and `
|
|
294
|
-
project's own environment manager, detected from its marker files —
|
|
295
|
-
`poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
296
|
-
the project root first, then the worktree root (workspace layouts keep one
|
|
297
|
-
top). With no marker, the active environment's bare `pytest` runs. An
|
|
298
|
-
`test_command` always wins.
|
|
293
|
+
`pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
|
|
294
|
+
the suite through the project's own environment manager, detected from its marker files —
|
|
295
|
+
`uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
296
|
+
checked at the project root first, then the worktree root (workspace layouts keep one
|
|
297
|
+
lockfile at the top). With no marker, the active environment's bare `pytest` runs. An
|
|
298
|
+
explicit `test_command` always wins.
|
|
299
|
+
|
|
300
|
+
The `gradle` adapter drives Kotlin/JVM and Android projects. It runs the project's Gradle
|
|
301
|
+
test task and reads per-test verdicts from the JUnit XML Gradle writes, rather than
|
|
302
|
+
scraping the console. The task is a config choice — `./gradlew test` or `testDebugUnitTest`
|
|
303
|
+
for fast JVM unit tests, `connectedDebugAndroidTest` for instrumented tests on a device
|
|
304
|
+
(give those a longer `timeout` and a `lease` so only one run touches the device at a time).
|
|
305
|
+
A *compile* failure maps to `not_collected`, not `failed`: Kotlin has no separate
|
|
306
|
+
collection phase, so a stub referencing a missing symbol fails the build, and the "stub
|
|
307
|
+
before RED" discipline holds exactly as it does for a Python import error.
|
|
308
|
+
|
|
309
|
+
```toml
|
|
310
|
+
[project.android-app]
|
|
311
|
+
root = "android-app"
|
|
312
|
+
adapter = "gradle"
|
|
313
|
+
test_paths = ["src/test/"]
|
|
314
|
+
test_command = "./gradlew testDebugUnitTest"
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The `xctest` adapter drives Swift and Objective-C projects through `xcodebuild`. Test ids
|
|
318
|
+
use xcodebuild's own `-only-testing:` format — `BundleName/ClassName/testMethodName` — so
|
|
319
|
+
a single XCTest can be driven through RED → GREEN without any id translation. Collection
|
|
320
|
+
prefers `xcodebuild test -enumerate-tests` (Xcode 16+) and falls back to grepping
|
|
321
|
+
`class Foo: XCTestCase` / `func testBar()` out of the Swift sources under `test_paths`,
|
|
322
|
+
deriving the bundle name from `-scheme`. As with gradle, a *build* failure maps to
|
|
323
|
+
`not_collected` rather than `failed`, so a stub referencing a missing symbol is not
|
|
324
|
+
mistaken for RED.
|
|
325
|
+
|
|
326
|
+
`test_command` is required here — the adapter cannot guess the scheme, destination, or
|
|
327
|
+
derived-data path. It appends `-only-testing:` for targeted runs and `-enumerate-tests`
|
|
328
|
+
for collection, and changes nothing else. Simulator runs are serial in practice: give
|
|
329
|
+
them a `lease` so two projects don't drive the same simulator at once.
|
|
330
|
+
|
|
331
|
+
```toml
|
|
332
|
+
[project.native-ios]
|
|
333
|
+
root = "native-ios"
|
|
334
|
+
adapter = "xctest"
|
|
335
|
+
test_paths = ["AppTests/"]
|
|
336
|
+
test_command = """xcodebuild test \
|
|
337
|
+
-project App.xcodeproj \
|
|
338
|
+
-scheme AppTests \
|
|
339
|
+
-destination 'platform=iOS Simulator,name=App-Unit' \
|
|
340
|
+
-derivedDataPath /tmp/app-unit-dd"""
|
|
341
|
+
lease = "ios-simulator"
|
|
342
|
+
timeout = 900
|
|
343
|
+
```
|
|
299
344
|
|
|
300
345
|
Third-party adapters register under the
|
|
301
346
|
`tddcli.adapters` entry-point group:
|
|
@@ -358,14 +403,49 @@ and is never reclassified as a pin.
|
|
|
358
403
|
| `tdd metrics` | fidelity, attempts, violations, interventions |
|
|
359
404
|
| `tdd fleet [--json]` | all active runs across every worktree; read-only |
|
|
360
405
|
|
|
406
|
+
## Scoped baseline capture (R9.5c)
|
|
407
|
+
|
|
408
|
+
`run start` probes only the projects the plan can actually reach: the declared cycle projects
|
|
409
|
+
plus the transitive `consumed_by` closure of artifacts whose producer is in that set. Projects
|
|
410
|
+
outside the reachable set never run during the plan, so their baseline is never subtracted from
|
|
411
|
+
anything — probing them is pure overhead. A `baseline_scoped` integrity event records which
|
|
412
|
+
projects were skipped, so the scoping is auditable.
|
|
413
|
+
|
|
414
|
+
Pass `--baseline-all` to probe every project in `tdd.toml` regardless:
|
|
415
|
+
|
|
416
|
+
tdd run start --plan tasks/plan.md --baseline-all
|
|
417
|
+
|
|
418
|
+
Use this when a cycle may edit files outside the predicted reachable set and you want every
|
|
419
|
+
project baselined up front rather than hitting the `no_baseline_for_project` escape hatch later.
|
|
420
|
+
|
|
421
|
+
### When a sweep reaches an un-baselined project (R9.5d)
|
|
422
|
+
|
|
423
|
+
If an edit during a run touches a file owned by an artifact that was outside the predicted
|
|
424
|
+
reachable set, the close sweep may pull in a project that was never baselined. Its failures are
|
|
425
|
+
unattributable — no baseline exists to subtract — so the sweep replies `resolve_blocker` with
|
|
426
|
+
kind `no_baseline_for_project` rather than mislabelling them as regressions. Recovery:
|
|
427
|
+
|
|
428
|
+
tdd blocker --kind no_baseline_for_project --detail "svc pulled in unexpectedly"
|
|
429
|
+
tdd resume --unblock --accept-failures --note "folding svc sweep failures into baseline"
|
|
430
|
+
|
|
431
|
+
`--accept-failures` inserts a fresh baseline row for the un-baselined project (recording those
|
|
432
|
+
failures as pre-existing) and records a `baseline_amended` event. The next advance proceeds
|
|
433
|
+
with the project properly baselined.
|
|
434
|
+
|
|
361
435
|
## Running a long baseline
|
|
362
436
|
|
|
363
|
-
`run start` probes
|
|
437
|
+
`run start` probes the reachable project set (R9.5c) before a run exists, and on a real project
|
|
364
438
|
that can take minutes — well past an agent harness's default Bash timeout. If the command
|
|
365
439
|
appears to hang or time out, **do not re-run it**: the probe is still making progress in the
|
|
366
440
|
background, and a second `run start` against the same worktree is refused with
|
|
367
441
|
`reason: "baseline_in_progress"` — retrying on timeout just stacks refusals on top of a
|
|
368
|
-
baseline that was never stuck.
|
|
442
|
+
baseline that was never stuck.
|
|
443
|
+
|
|
444
|
+
`tdd advance` is similarly single-flight per worktree. A close sweep (artifact regeneration, full
|
|
445
|
+
suite, lint, typecheck) can run for several minutes. If an advance appears to hang, **do not
|
|
446
|
+
re-run it**: a second `advance` against the same worktree is refused with
|
|
447
|
+
`reason: "advance_in_flight"`. The refusal carries `pid`, `started_at`, and `elapsed_s` so the
|
|
448
|
+
agent can confirm the first process is still alive. Run `tdd status` to see the current run state. In order of preference:
|
|
369
449
|
|
|
370
450
|
1. **Background it.** Run `tdd run start` in the background if your harness supports it. The
|
|
371
451
|
heartbeat (`baseline_captured` / `project_completed` lines on stderr) lands in the task log
|
|
@@ -262,12 +262,57 @@ the next plan is written.
|
|
|
262
262
|
|
|
263
263
|
## Adapters
|
|
264
264
|
|
|
265
|
-
`pytest` and `
|
|
266
|
-
project's own environment manager, detected from its marker files —
|
|
267
|
-
`poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
268
|
-
the project root first, then the worktree root (workspace layouts keep one
|
|
269
|
-
top). With no marker, the active environment's bare `pytest` runs. An
|
|
270
|
-
`test_command` always wins.
|
|
265
|
+
`pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
|
|
266
|
+
the suite through the project's own environment manager, detected from its marker files —
|
|
267
|
+
`uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
268
|
+
checked at the project root first, then the worktree root (workspace layouts keep one
|
|
269
|
+
lockfile at the top). With no marker, the active environment's bare `pytest` runs. An
|
|
270
|
+
explicit `test_command` always wins.
|
|
271
|
+
|
|
272
|
+
The `gradle` adapter drives Kotlin/JVM and Android projects. It runs the project's Gradle
|
|
273
|
+
test task and reads per-test verdicts from the JUnit XML Gradle writes, rather than
|
|
274
|
+
scraping the console. The task is a config choice — `./gradlew test` or `testDebugUnitTest`
|
|
275
|
+
for fast JVM unit tests, `connectedDebugAndroidTest` for instrumented tests on a device
|
|
276
|
+
(give those a longer `timeout` and a `lease` so only one run touches the device at a time).
|
|
277
|
+
A *compile* failure maps to `not_collected`, not `failed`: Kotlin has no separate
|
|
278
|
+
collection phase, so a stub referencing a missing symbol fails the build, and the "stub
|
|
279
|
+
before RED" discipline holds exactly as it does for a Python import error.
|
|
280
|
+
|
|
281
|
+
```toml
|
|
282
|
+
[project.android-app]
|
|
283
|
+
root = "android-app"
|
|
284
|
+
adapter = "gradle"
|
|
285
|
+
test_paths = ["src/test/"]
|
|
286
|
+
test_command = "./gradlew testDebugUnitTest"
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The `xctest` adapter drives Swift and Objective-C projects through `xcodebuild`. Test ids
|
|
290
|
+
use xcodebuild's own `-only-testing:` format — `BundleName/ClassName/testMethodName` — so
|
|
291
|
+
a single XCTest can be driven through RED → GREEN without any id translation. Collection
|
|
292
|
+
prefers `xcodebuild test -enumerate-tests` (Xcode 16+) and falls back to grepping
|
|
293
|
+
`class Foo: XCTestCase` / `func testBar()` out of the Swift sources under `test_paths`,
|
|
294
|
+
deriving the bundle name from `-scheme`. As with gradle, a *build* failure maps to
|
|
295
|
+
`not_collected` rather than `failed`, so a stub referencing a missing symbol is not
|
|
296
|
+
mistaken for RED.
|
|
297
|
+
|
|
298
|
+
`test_command` is required here — the adapter cannot guess the scheme, destination, or
|
|
299
|
+
derived-data path. It appends `-only-testing:` for targeted runs and `-enumerate-tests`
|
|
300
|
+
for collection, and changes nothing else. Simulator runs are serial in practice: give
|
|
301
|
+
them a `lease` so two projects don't drive the same simulator at once.
|
|
302
|
+
|
|
303
|
+
```toml
|
|
304
|
+
[project.native-ios]
|
|
305
|
+
root = "native-ios"
|
|
306
|
+
adapter = "xctest"
|
|
307
|
+
test_paths = ["AppTests/"]
|
|
308
|
+
test_command = """xcodebuild test \
|
|
309
|
+
-project App.xcodeproj \
|
|
310
|
+
-scheme AppTests \
|
|
311
|
+
-destination 'platform=iOS Simulator,name=App-Unit' \
|
|
312
|
+
-derivedDataPath /tmp/app-unit-dd"""
|
|
313
|
+
lease = "ios-simulator"
|
|
314
|
+
timeout = 900
|
|
315
|
+
```
|
|
271
316
|
|
|
272
317
|
Third-party adapters register under the
|
|
273
318
|
`tddcli.adapters` entry-point group:
|
|
@@ -330,14 +375,49 @@ and is never reclassified as a pin.
|
|
|
330
375
|
| `tdd metrics` | fidelity, attempts, violations, interventions |
|
|
331
376
|
| `tdd fleet [--json]` | all active runs across every worktree; read-only |
|
|
332
377
|
|
|
378
|
+
## Scoped baseline capture (R9.5c)
|
|
379
|
+
|
|
380
|
+
`run start` probes only the projects the plan can actually reach: the declared cycle projects
|
|
381
|
+
plus the transitive `consumed_by` closure of artifacts whose producer is in that set. Projects
|
|
382
|
+
outside the reachable set never run during the plan, so their baseline is never subtracted from
|
|
383
|
+
anything — probing them is pure overhead. A `baseline_scoped` integrity event records which
|
|
384
|
+
projects were skipped, so the scoping is auditable.
|
|
385
|
+
|
|
386
|
+
Pass `--baseline-all` to probe every project in `tdd.toml` regardless:
|
|
387
|
+
|
|
388
|
+
tdd run start --plan tasks/plan.md --baseline-all
|
|
389
|
+
|
|
390
|
+
Use this when a cycle may edit files outside the predicted reachable set and you want every
|
|
391
|
+
project baselined up front rather than hitting the `no_baseline_for_project` escape hatch later.
|
|
392
|
+
|
|
393
|
+
### When a sweep reaches an un-baselined project (R9.5d)
|
|
394
|
+
|
|
395
|
+
If an edit during a run touches a file owned by an artifact that was outside the predicted
|
|
396
|
+
reachable set, the close sweep may pull in a project that was never baselined. Its failures are
|
|
397
|
+
unattributable — no baseline exists to subtract — so the sweep replies `resolve_blocker` with
|
|
398
|
+
kind `no_baseline_for_project` rather than mislabelling them as regressions. Recovery:
|
|
399
|
+
|
|
400
|
+
tdd blocker --kind no_baseline_for_project --detail "svc pulled in unexpectedly"
|
|
401
|
+
tdd resume --unblock --accept-failures --note "folding svc sweep failures into baseline"
|
|
402
|
+
|
|
403
|
+
`--accept-failures` inserts a fresh baseline row for the un-baselined project (recording those
|
|
404
|
+
failures as pre-existing) and records a `baseline_amended` event. The next advance proceeds
|
|
405
|
+
with the project properly baselined.
|
|
406
|
+
|
|
333
407
|
## Running a long baseline
|
|
334
408
|
|
|
335
|
-
`run start` probes
|
|
409
|
+
`run start` probes the reachable project set (R9.5c) before a run exists, and on a real project
|
|
336
410
|
that can take minutes — well past an agent harness's default Bash timeout. If the command
|
|
337
411
|
appears to hang or time out, **do not re-run it**: the probe is still making progress in the
|
|
338
412
|
background, and a second `run start` against the same worktree is refused with
|
|
339
413
|
`reason: "baseline_in_progress"` — retrying on timeout just stacks refusals on top of a
|
|
340
|
-
baseline that was never stuck.
|
|
414
|
+
baseline that was never stuck.
|
|
415
|
+
|
|
416
|
+
`tdd advance` is similarly single-flight per worktree. A close sweep (artifact regeneration, full
|
|
417
|
+
suite, lint, typecheck) can run for several minutes. If an advance appears to hang, **do not
|
|
418
|
+
re-run it**: a second `advance` against the same worktree is refused with
|
|
419
|
+
`reason: "advance_in_flight"`. The refusal carries `pid`, `started_at`, and `elapsed_s` so the
|
|
420
|
+
agent can confirm the first process is still alive. Run `tdd status` to see the current run state. In order of preference:
|
|
341
421
|
|
|
342
422
|
1. **Background it.** Run `tdd run start` in the background if your harness supports it. The
|
|
343
423
|
heartbeat (`baseline_captured` / `project_completed` lines on stderr) lands in the task log
|
|
@@ -5,12 +5,14 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
from .base import Adapter, Collection, GateResult, Verdict
|
|
7
7
|
from .exec_adapter import ExecAdapter
|
|
8
|
+
from .gradle_adapter import GradleAdapter
|
|
8
9
|
from .pytest_adapter import PytestAdapter
|
|
9
10
|
from .vitest_adapter import VitestAdapter
|
|
10
11
|
from .xctest_adapter import XCTestAdapter
|
|
11
12
|
|
|
12
13
|
REGISTRY: dict[str, type[Adapter]] = {
|
|
13
14
|
"exec": ExecAdapter,
|
|
15
|
+
"gradle": GradleAdapter,
|
|
14
16
|
"pytest": PytestAdapter,
|
|
15
17
|
"vitest": VitestAdapter,
|
|
16
18
|
"xctest": XCTestAdapter,
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""gradle adapter — Gradle driver for Kotlin/JVM and Android projects (§10).
|
|
2
|
+
|
|
3
|
+
Runs the project's Gradle test task (`./gradlew test`, `testDebugUnitTest`,
|
|
4
|
+
`connectedDebugAndroidTest`, …) and reads the per-test verdicts from the
|
|
5
|
+
JUnit XML Gradle always writes. Console output is *not* parsed: Gradle's
|
|
6
|
+
console is a task-level summary, not a reliable per-test log, whereas the XML
|
|
7
|
+
under `build/test-results/` (unit) and `build/outputs/.../androidTest-results/`
|
|
8
|
+
(instrumented) carries an exact `classname` / `name` / `failure` for every
|
|
9
|
+
case. Both unit and instrumented tasks write the same JUnit shape, so one
|
|
10
|
+
parser serves both — the task is a config choice (`test_command`), not a code
|
|
11
|
+
path.
|
|
12
|
+
|
|
13
|
+
Test ids use `FQCN/method` so a targeted run composes to Gradle's own filter
|
|
14
|
+
without translation:
|
|
15
|
+
|
|
16
|
+
com.example.PollCadenceTest/scalesOnlyUnderE2E
|
|
17
|
+
|
|
18
|
+
The adapter appends `--tests com.example.PollCadenceTest.scalesOnlyUnderE2E`
|
|
19
|
+
for a targeted run; nothing else.
|
|
20
|
+
|
|
21
|
+
A *compile* failure maps to `not_collected`, not `failed`. Kotlin has no
|
|
22
|
+
separate collection phase — a test referencing a missing symbol fails
|
|
23
|
+
`compileTestKotlin`, not the run — so a compile error is treated like Python's
|
|
24
|
+
import error and Swift's build failure: write a compiling stub, then observe
|
|
25
|
+
the assertion failure. The discriminator is not "BUILD FAILED" (a *test*
|
|
26
|
+
failure prints that too) but *whether any fresh JUnit XML was produced*: tests
|
|
27
|
+
that ran leave results, a compile error leaves none.
|
|
28
|
+
|
|
29
|
+
Stale results (R10.3): Gradle leaves the previous run's XML on disk and skips
|
|
30
|
+
up-to-date tasks, so a targeted `--tests` run could otherwise be scored against
|
|
31
|
+
another test's old XML. The adapter deletes the result files before each run —
|
|
32
|
+
which also invalidates Gradle's up-to-date check, forcing the task to actually
|
|
33
|
+
re-run — so whatever XML exists afterward belongs to this invocation alone.
|
|
34
|
+
|
|
35
|
+
Collection is a per-file grep of Kotlin/Java sources (R10.3's fallback):
|
|
36
|
+
Gradle has no cheap whole-suite test enumerator, so there is no batch path.
|
|
37
|
+
One uncollectable file is recorded against itself and cannot destroy the set.
|
|
38
|
+
|
|
39
|
+
Config (`tdd.toml`):
|
|
40
|
+
|
|
41
|
+
[project.android-app]
|
|
42
|
+
root = "android-app"
|
|
43
|
+
adapter = "gradle"
|
|
44
|
+
test_paths = ["src/test/"] # Kotlin/Java test sources
|
|
45
|
+
test_command = "./gradlew testDebugUnitTest" # JVM unit tests (fast)
|
|
46
|
+
|
|
47
|
+
Instrumented tests run the same way through a different task; give them a
|
|
48
|
+
longer timeout and a `lease` so only one run touches a device at a time:
|
|
49
|
+
|
|
50
|
+
[project.android-e2e]
|
|
51
|
+
root = "android-app"
|
|
52
|
+
adapter = "gradle"
|
|
53
|
+
test_paths = ["src/androidTest/"]
|
|
54
|
+
test_command = "./gradlew connectedDebugAndroidTest"
|
|
55
|
+
lease = "android-emulator"
|
|
56
|
+
timeout = 1200
|
|
57
|
+
in_close_sweep = false # too slow per cycle
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import re
|
|
63
|
+
import shlex
|
|
64
|
+
import time
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from xml.etree import ElementTree
|
|
67
|
+
|
|
68
|
+
from .base import (
|
|
69
|
+
FAILED,
|
|
70
|
+
NOT_COLLECTED,
|
|
71
|
+
PASSED,
|
|
72
|
+
Adapter,
|
|
73
|
+
Collection,
|
|
74
|
+
GateResult,
|
|
75
|
+
Verdict,
|
|
76
|
+
clip_failure,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Patterns applied to gradle console output (only to classify a run that
|
|
81
|
+
# produced no test results — the XML is authoritative for everything else)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
_BUILD_FAILED_RE = re.compile(r"BUILD FAILED")
|
|
85
|
+
|
|
86
|
+
# A --tests filter that matched nothing: the target is not_found, not a failure.
|
|
87
|
+
# > No tests found for given includes: [com.example.FooTest.bar](--tests filter)
|
|
88
|
+
_NO_TESTS_RE = re.compile(r"No tests found for given includes")
|
|
89
|
+
|
|
90
|
+
# Kotlin compiler errors ("e: file:line:col: message") and javac errors.
|
|
91
|
+
_KOTLIN_ERROR_RE = re.compile(r"^e: .*$", re.MULTILINE)
|
|
92
|
+
|
|
93
|
+
# Kotlin/Java source patterns for the per-file grep fallback.
|
|
94
|
+
_PACKAGE_RE = re.compile(r"^\s*package\s+([\w.]+)", re.MULTILINE)
|
|
95
|
+
_CLASS_RE = re.compile(r"\bclass\s+(\w+)")
|
|
96
|
+
# `@Test` (JUnit 4/5) followed by the annotated function/method name. Handles
|
|
97
|
+
# Kotlin `fun name`, Kotlin backtick names (`fun \`does the thing\`()`), and
|
|
98
|
+
# Java `void name()`. Cross-class attribution within a file is not scope-aware
|
|
99
|
+
# — the batch (a real run) is authoritative; this is the fallback.
|
|
100
|
+
_METHOD_RE = re.compile(
|
|
101
|
+
r"@Test\b.*?\b(?:fun|void)\s+(?:`(?P<bt>[^`]+)`|(?P<id>\w+))",
|
|
102
|
+
re.DOTALL,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class GradleAdapter(Adapter):
|
|
107
|
+
name = "gradle"
|
|
108
|
+
|
|
109
|
+
def stub_hint(self) -> str:
|
|
110
|
+
return (
|
|
111
|
+
"a test method body that compiles but fails its assertion — a missing"
|
|
112
|
+
" symbol is a *compile* failure (not_collected), not RED; write a stub"
|
|
113
|
+
' that compiles first (e.g. `fail("not implemented")`), then observe the'
|
|
114
|
+
" assertion failure"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Core command
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _test_cmd(self) -> str:
|
|
122
|
+
return self.project.test_command or "./gradlew test"
|
|
123
|
+
|
|
124
|
+
def _gradle_filter(self, native_id: str) -> str:
|
|
125
|
+
"""Translate an `FQCN/method` id into Gradle's `--tests FQCN.method` form.
|
|
126
|
+
|
|
127
|
+
A bare class id (no `/`) is passed through, targeting the whole class.
|
|
128
|
+
"""
|
|
129
|
+
if "/" in native_id:
|
|
130
|
+
cls, method = native_id.rsplit("/", 1)
|
|
131
|
+
return f"{cls}.{method}"
|
|
132
|
+
return native_id
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Discovery — Kotlin / Java source files
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _test_files(self) -> list[Path]:
|
|
139
|
+
found: set[Path] = set()
|
|
140
|
+
for pattern in self.project.test_patterns or []:
|
|
141
|
+
if pattern.endswith("/"):
|
|
142
|
+
base = self.root / pattern
|
|
143
|
+
if base.is_dir():
|
|
144
|
+
for suffix in ("*.kt", "*.java"):
|
|
145
|
+
found.update(p for p in base.rglob(suffix) if p.is_file())
|
|
146
|
+
else:
|
|
147
|
+
found.update(p for p in self.root.glob(pattern) if p.is_file())
|
|
148
|
+
return sorted(found)
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# Collection — per-file grep (no cheap whole-suite enumerator exists)
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def collect(self) -> Collection:
|
|
155
|
+
result = Collection()
|
|
156
|
+
for path in self._test_files():
|
|
157
|
+
rel = str(path.relative_to(self.root))
|
|
158
|
+
try:
|
|
159
|
+
result.tests |= self._grep_tests(path)
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
result.failed_files[rel] = str(exc)
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
def _grep_tests(self, path: Path) -> set[str]:
|
|
165
|
+
"""Extract `pkg.Class/method` ids from one Kotlin/Java source file.
|
|
166
|
+
|
|
167
|
+
Finds every `@Test`-annotated method and every declared class, forming
|
|
168
|
+
`pkg.Class/method` ids qualified by the package declaration. Matches
|
|
169
|
+
are file-order, not scope-aware — a file with two classes attributes
|
|
170
|
+
each test method to both. The batch path is authoritative; this is the
|
|
171
|
+
fallback that keeps one unreadable file from destroying the whole set.
|
|
172
|
+
"""
|
|
173
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
174
|
+
pkg_match = _PACKAGE_RE.search(text)
|
|
175
|
+
pkg = f"{pkg_match.group(1)}." if pkg_match else ""
|
|
176
|
+
classes = _CLASS_RE.findall(text)
|
|
177
|
+
methods = [m.group("bt") or m.group("id") for m in _METHOD_RE.finditer(text)]
|
|
178
|
+
ids: set[str] = set()
|
|
179
|
+
for class_ in classes:
|
|
180
|
+
for method in methods:
|
|
181
|
+
ids.add(self.qualify(f"{pkg}{class_}/{method}"))
|
|
182
|
+
return ids
|
|
183
|
+
|
|
184
|
+
# The base collect() calls these three; gradle overrides collect() entirely
|
|
185
|
+
# so they are never reached. Trivial bodies avoid surprising a direct caller.
|
|
186
|
+
def _collect_invocations(self):
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
def _collect_batch(self, command, env):
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def _collect_per_file(self, rels, result):
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Doctor gate
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def collectable(self) -> GateResult:
|
|
200
|
+
"""Collection is a source grep — it needs no toolchain, so enumeration is
|
|
201
|
+
always possible. Whether Gradle itself can run is checked by the
|
|
202
|
+
`gradle wrapper present` branch in `tdd doctor` (§8.1)."""
|
|
203
|
+
return GateResult(ok=True)
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# JUnit XML results
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def _result_files(self) -> list[Path]:
|
|
210
|
+
"""Every JUnit XML file Gradle writes for this project's tests.
|
|
211
|
+
|
|
212
|
+
Covers both JVM unit tasks (`build/test-results/<task>/TEST-*.xml`) and
|
|
213
|
+
Android instrumented tasks (`build/outputs/.../androidTest-results/`),
|
|
214
|
+
across every module under the project root.
|
|
215
|
+
"""
|
|
216
|
+
found: set[Path] = set()
|
|
217
|
+
found.update(self.root.glob("**/build/test-results/**/*.xml"))
|
|
218
|
+
found.update(self.root.glob("**/build/outputs/**/androidTest-results/**/*.xml"))
|
|
219
|
+
return sorted(found)
|
|
220
|
+
|
|
221
|
+
def _clear_results(self) -> None:
|
|
222
|
+
"""Delete stale JUnit XML before a run.
|
|
223
|
+
|
|
224
|
+
Two problems in one: Gradle leaves the previous run's XML on disk, and
|
|
225
|
+
it skips up-to-date tasks (so no fresh XML is written). Removing the
|
|
226
|
+
result files fixes both — a targeted run is scored only against the XML
|
|
227
|
+
it produced, and deleting a task's output invalidates its up-to-date
|
|
228
|
+
check so the task actually re-runs.
|
|
229
|
+
"""
|
|
230
|
+
for path in self._result_files():
|
|
231
|
+
try:
|
|
232
|
+
path.unlink()
|
|
233
|
+
except OSError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
def _parse_results(self) -> tuple[set[str], set[str], dict[str, str]]:
|
|
237
|
+
"""Parse every JUnit XML file into `(passed, failed, failures)`.
|
|
238
|
+
|
|
239
|
+
A `<testcase>` with a `<failure>`/`<error>` child failed; with a
|
|
240
|
+
`<skipped>` child it is neither (excluded); otherwise it passed. Ids are
|
|
241
|
+
qualified. `failures` maps a failed id to its message + stack trace.
|
|
242
|
+
"""
|
|
243
|
+
passed: set[str] = set()
|
|
244
|
+
failed: set[str] = set()
|
|
245
|
+
failures: dict[str, str] = {}
|
|
246
|
+
for path in self._result_files():
|
|
247
|
+
try:
|
|
248
|
+
tree = ElementTree.parse(path)
|
|
249
|
+
except ElementTree.ParseError:
|
|
250
|
+
continue
|
|
251
|
+
for case in tree.iter("testcase"):
|
|
252
|
+
classname = case.get("classname") or ""
|
|
253
|
+
method = case.get("name") or ""
|
|
254
|
+
raw = f"{classname}/{method}" if classname else method
|
|
255
|
+
qualified = self.qualify(raw)
|
|
256
|
+
failure = case.find("failure")
|
|
257
|
+
if failure is None:
|
|
258
|
+
failure = case.find("error")
|
|
259
|
+
if failure is not None:
|
|
260
|
+
failed.add(qualified)
|
|
261
|
+
failures[qualified] = self._failure_text(failure)
|
|
262
|
+
elif case.find("skipped") is None:
|
|
263
|
+
passed.add(qualified)
|
|
264
|
+
return passed, failed, failures
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def _failure_text(failure: ElementTree.Element) -> str:
|
|
268
|
+
message = failure.get("message") or ""
|
|
269
|
+
body = (failure.text or "").strip()
|
|
270
|
+
combined = "\n".join(part for part in (message, body) if part)
|
|
271
|
+
return clip_failure(combined) if combined else "test failed"
|
|
272
|
+
|
|
273
|
+
def _build_errors(self, combined: str) -> str:
|
|
274
|
+
"""Lines that explain why a run produced no tests: compiler errors and
|
|
275
|
+
Gradle's own failure report."""
|
|
276
|
+
wanted = ("e: ", ": error:", "FAILED", "FAILURE:", "What went wrong", "Execution failed")
|
|
277
|
+
lines = [line for line in combined.splitlines() if any(w in line for w in wanted)]
|
|
278
|
+
return clip_failure("\n".join(lines)) if lines else clip_failure(combined)
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------------------
|
|
281
|
+
# Suite run
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
285
|
+
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
286
|
+
env = self._suite_env(None)
|
|
287
|
+
|
|
288
|
+
cmd = self._test_cmd()
|
|
289
|
+
if target is not None:
|
|
290
|
+
native = self.strip(target)
|
|
291
|
+
cmd = f"{cmd} --tests {shlex.quote(self._gradle_filter(native))}"
|
|
292
|
+
|
|
293
|
+
started = time.monotonic()
|
|
294
|
+
self._clear_results()
|
|
295
|
+
code, out, err = self._run_suite(cmd, env)
|
|
296
|
+
verdict.duration_ms = int((time.monotonic() - started) * 1000)
|
|
297
|
+
combined = out + "\n" + err
|
|
298
|
+
|
|
299
|
+
passed, failed, failures = self._parse_results()
|
|
300
|
+
verdict.passed = sorted(passed)
|
|
301
|
+
verdict.failed = sorted(failed)
|
|
302
|
+
|
|
303
|
+
# No test ran. Distinguish "filter matched nothing" (not_found) from a
|
|
304
|
+
# compile/config failure (not_collected) from a genuinely empty suite.
|
|
305
|
+
if not passed and not failed:
|
|
306
|
+
if _NO_TESTS_RE.search(combined):
|
|
307
|
+
return verdict # target stays NOT_FOUND; empty full run is clean
|
|
308
|
+
if code != 0 or _BUILD_FAILED_RE.search(combined):
|
|
309
|
+
errors = self._build_errors(combined)
|
|
310
|
+
if target is not None:
|
|
311
|
+
verdict.target_outcome = NOT_COLLECTED
|
|
312
|
+
verdict.target_failure = errors
|
|
313
|
+
else:
|
|
314
|
+
verdict.error = errors
|
|
315
|
+
return verdict
|
|
316
|
+
|
|
317
|
+
if target is None:
|
|
318
|
+
return verdict
|
|
319
|
+
|
|
320
|
+
if target in passed:
|
|
321
|
+
verdict.target_outcome = PASSED
|
|
322
|
+
elif target in failed:
|
|
323
|
+
verdict.target_outcome = FAILED
|
|
324
|
+
verdict.target_failure = failures.get(target, "test failed")
|
|
325
|
+
# else NOT_FOUND (default): the suite ran but never produced this id
|
|
326
|
+
|
|
327
|
+
return verdict
|