test_cmd.py python
809 lines 26.0 KB
Raw
sha256:0e3697f29ae6c62f39818a19861d4db6276465de16481fadf866eb93cbf4aae4 test: validate staging push and MP workflow for aaronrene Human 16 days ago
1 """muse code test — symbol-graph–driven test selection and execution.
2
3 The most powerful test command ever built for a version control system.
4
5 Traditional test runners are file-aware at best. You run a test file, it
6 either passes or fails. You change 200 files and hope your CI matrix covers
7 the right subset. You run the full suite and wait ten minutes.
8
9 ``muse code test`` is different. It knows exactly which symbols changed,
10 which tests call those symbols (via the committed call graph), and which
11 tests have historically been flaky. It runs the minimum set of tests needed
12 to validate your changes — and it prioritises failing tests to surface
13 problems in seconds, not minutes.
14
15 How it works
16 ------------
17 1. **Diff** — compare HEAD snapshot symbols against the working tree to find
18 every modified, added, or deleted symbol.
19 2. **Graph** — BFS through the call graph from every test function to find
20 which tests transitively call each changed symbol.
21 3. **Prioritise** — order tests by risk: failure streaks first, flaky tests
22 second, unknown tests third, slow tests to the front of parallel queues.
23 4. **Execute** — run the selected tests as isolated subprocesses with
24 configurable parallelism and a wall-clock budget.
25 5. **Record** — persist pass/fail results to ``.muse/test_history.msgpack``
26 for future prioritisation and flaky-test detection.
27
28 Usage::
29
30 # Run tests for all symbols changed vs HEAD (smart selection, default)
31 muse code test
32
33 # Run all tests (no selection, equivalent to pytest tests/)
34 muse code test --all
35
36 # Select tests covering a specific symbol
37 muse code test --symbol "muse/core/store.py::read_commit"
38
39 # Run a specific file or node ID directly
40 muse code test tests/test_core_store.py
41 muse code test "tests/test_core_store.py::TestReadCommit::test_returns_none_on_missing"
42
43 # Control execution
44 muse code test --workers 4 --timeout 120
45
46 # Show what would be run without running it
47 muse code test --dry-run
48
49 # Show historical summary (pass rates, flaky tests)
50 muse code test --history
51 muse code test --flaky
52
53 # Run full CI gate suite (.muse/ci.toml)
54 muse code test --ci
55
56 # Machine-readable output
57 muse code test --json
58
59 Flags
60 -----
61 ``TARGET [TARGET ...]``
62 Optional pytest node IDs or file paths to run directly (bypasses graph
63 selection).
64
65 ``--all, -a``
66 Ignore the working-tree diff; run all discovered tests.
67
68 ``--symbol ADDR, -s ADDR``
69 Force-select tests covering the given symbol address
70 (``"path/to/file.py::Name"``). May be specified multiple times.
71
72 ``--depth N, -d N``
73 Call-graph BFS depth for test selection (default 3).
74
75 ``--workers N, -w N``
76 Number of parallel subprocess partitions (default 1).
77
78 ``--timeout S``
79 Wall-clock budget per partition in seconds (default 0 = unlimited).
80
81 ``--dry-run``
82 Print selected tests without executing them.
83
84 ``--no-save``
85 Do not persist results to ``.muse/test_history.msgpack``.
86
87 ``--history``
88 Print a summary of historical pass/fail rates and exit.
89
90 ``--flaky``
91 Print only tests with a history of intermittent failures and exit.
92
93 ``--ci``
94 Execute the full CI gate suite from ``.muse/ci.toml`` and exit.
95
96 ``--extra ARGS``
97 Extra arguments forwarded verbatim to pytest (e.g. ``-x``, ``-v``,
98 ``--timeout=30``).
99
100 ``--json``
101 Emit a machine-readable JSON result and exit.
102 """
103
104 from __future__ import annotations
105
106 import argparse
107 import json
108 import logging
109 import pathlib
110 import sys
111 from typing import NotRequired, TypedDict
112
113 from muse.core.ci import CiRunResult, GateResult, load_ci_config, run_ci
114 from muse.core.repo import require_repo
115 from muse.core.store import (
116 Manifest,
117 get_head_commit_id,
118 get_commit_snapshot_manifest,
119 read_current_branch,
120 )
121 from muse.core.test_history import (
122 HistorySummary,
123 RunRecord,
124 CaseRecord,
125 append_run,
126 flaky_tests,
127 iso_now,
128 load_history,
129 make_run_id,
130 prioritize_targets,
131 summarize,
132 )
133 from muse.core.test_runner import RunConfig, RunResult, CaseResult, run_tests
134 from muse.core.validation import sanitize_display
135 from muse.core.test_selection import (
136 ChangedSymbol,
137 SelectionResult,
138 SelectionTarget,
139 changed_symbols_from_diff,
140 select_tests,
141 )
142
143 type _HistoryMap = dict[str, "HistorySummary"]
144
145 logger = logging.getLogger(__name__)
146
147 # ---------------------------------------------------------------------------
148 # JSON output types
149 # ---------------------------------------------------------------------------
150
151
152 class _SelectionJson(TypedDict):
153 """JSON representation of the test-selection phase."""
154
155 changed_addresses: list[str]
156 covered_addresses: list[str]
157 uncovered_addresses: list[str]
158 coverage_fraction: float
159 fallback_used: bool
160 targets: list[str]
161
162
163 class _RunJson(TypedDict):
164 """JSON representation of the test execution phase."""
165
166 run_id: str
167 exit_code: int
168 duration_ms: float
169 total: int
170 passed: int
171 failed: int
172 errored: int
173 skipped: int
174 timed_out: bool
175 json_report_available: bool
176
177
178 class _TestResultJson(TypedDict):
179 """Per-test result in JSON output."""
180
181 node_id: str
182 outcome: str
183 duration_ms: float
184 longrepr: NotRequired[str]
185
186
187 class _HistoryJson(TypedDict):
188 """JSON representation of a HistorySummary."""
189
190 node_id: str
191 total_runs: int
192 pass_count: int
193 fail_count: int
194 skip_count: int
195 flaky: bool
196 avg_duration_ms: float
197 last_outcome: str | None
198 last_run_timestamp: str | None
199 fail_streak: int
200
201
202 class _CiGateJson(TypedDict):
203 """JSON representation of a single CI gate result."""
204
205 name: str
206 command: list[str]
207 exit_code: int
208 duration_ms: float
209 required: bool
210 passed: bool
211 timed_out: bool
212 stdout: str
213 stderr: str
214 warning: NotRequired[str]
215
216
217 class _CiJson(TypedDict):
218 """JSON representation of a full CI run."""
219
220 passed: bool
221 timestamp: str
222 total_duration_ms: float
223 gates: list[_CiGateJson]
224
225
226 class _FullJson(TypedDict):
227 """Top-level JSON output for ``muse code test``."""
228
229 mode: str
230 selection: NotRequired[_SelectionJson]
231 run: NotRequired[_RunJson]
232 results: NotRequired[list[_TestResultJson]]
233 history: NotRequired[list[_HistoryJson]]
234 ci: NotRequired[_CiJson]
235 error: NotRequired[str]
236
237
238 # ---------------------------------------------------------------------------
239 # Registration
240 # ---------------------------------------------------------------------------
241
242
243 def register(
244 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
245 ) -> None:
246 """Register the ``test`` subcommand under a code sub-parser."""
247 parser = subparsers.add_parser(
248 "test",
249 help="Symbol-graph–driven test selection and execution.",
250 description=__doc__,
251 formatter_class=argparse.RawDescriptionHelpFormatter,
252 )
253
254 parser.add_argument(
255 "targets",
256 nargs="*",
257 metavar="TARGET",
258 help="Optional pytest node IDs or file paths (bypasses graph selection).",
259 )
260 parser.add_argument(
261 "--all",
262 "-a",
263 action="store_true",
264 dest="run_all",
265 help="Run all tests regardless of working-tree diff.",
266 )
267 parser.add_argument(
268 "--symbol",
269 "-s",
270 action="append",
271 dest="symbols",
272 default=[],
273 metavar="ADDR",
274 help="Force-select tests covering this symbol address (repeatable).",
275 )
276 parser.add_argument(
277 "--depth",
278 "-d",
279 type=int,
280 default=3,
281 metavar="N",
282 help="Call-graph BFS depth for test selection (default 3).",
283 )
284 parser.add_argument(
285 "--workers",
286 "-w",
287 type=int,
288 default=1,
289 metavar="N",
290 help="Parallel subprocess partitions (default 1).",
291 )
292 parser.add_argument(
293 "--timeout",
294 type=float,
295 default=0.0,
296 metavar="S",
297 help="Wall-clock budget per partition in seconds (default 0 = unlimited).",
298 )
299 parser.add_argument(
300 "--dry-run",
301 action="store_true",
302 help="Print selected tests without executing them.",
303 )
304 parser.add_argument(
305 "--no-save",
306 action="store_true",
307 help="Do not persist results to test history.",
308 )
309 parser.add_argument(
310 "--history",
311 action="store_true",
312 help="Print historical pass/fail summary and exit.",
313 )
314 parser.add_argument(
315 "--flaky",
316 action="store_true",
317 help="Print only flaky tests from history and exit.",
318 )
319 parser.add_argument(
320 "--ci",
321 action="store_true",
322 help="Run the full CI gate suite from .muse/ci.toml.",
323 )
324 parser.add_argument(
325 "--extra",
326 nargs=argparse.REMAINDER,
327 default=[],
328 metavar="ARGS",
329 help="Extra arguments forwarded verbatim to pytest.",
330 )
331 parser.add_argument(
332 "--json",
333 action="store_true",
334 dest="json_output",
335 help="Emit machine-readable JSON.",
336 )
337
338 parser.set_defaults(func=run)
339
340
341 # ---------------------------------------------------------------------------
342 # History display
343 # ---------------------------------------------------------------------------
344
345
346 def _print_history(summaries: _HistoryMap, *, flaky_only: bool) -> None:
347 """Render the history table to stdout."""
348 entries = sorted(summaries.values(), key=lambda s: s["fail_count"], reverse=True)
349 if flaky_only:
350 entries = [e for e in entries if e["flaky"]]
351
352 if not entries:
353 print("No test history recorded." if not flaky_only else "No flaky tests found.")
354 return
355
356 hdr = f"{'NODE ID':<70} {'RUNS':>5} {'PASS':>5} {'FAIL':>5} {'FLAKY':>6} {'AVG ms':>8} {'STREAK':>7}"
357 print(hdr)
358 print("─" * len(hdr))
359 for s in entries:
360 flaky_flag = "✓" if s["flaky"] else ""
361 print(
362 f"{s['node_id']:<70} "
363 f"{s['total_runs']:>5} "
364 f"{s['pass_count']:>5} "
365 f"{s['fail_count']:>5} "
366 f"{flaky_flag:>6} "
367 f"{s['avg_duration_ms']:>8.1f} "
368 f"{s['fail_streak']:>7}"
369 )
370
371
372 def _history_to_json(s: HistorySummary) -> _HistoryJson:
373 return _HistoryJson(
374 node_id=s["node_id"],
375 total_runs=s["total_runs"],
376 pass_count=s["pass_count"],
377 fail_count=s["fail_count"],
378 skip_count=s["skip_count"],
379 flaky=s["flaky"],
380 avg_duration_ms=s["avg_duration_ms"],
381 last_outcome=s["last_outcome"],
382 last_run_timestamp=s["last_run_timestamp"],
383 fail_streak=s["fail_streak"],
384 )
385
386
387 # ---------------------------------------------------------------------------
388 # CI display
389 # ---------------------------------------------------------------------------
390
391
392 def _print_ci_result(result: CiRunResult) -> None:
393 """Render CI gate results to stdout."""
394 width = 72
395 print()
396 print("CI gate results")
397 print("─" * width)
398 for gate in result["gates"]:
399 icon = "✅" if gate["passed"] else ("⚠️ " if not gate["required"] else "❌")
400 ms = gate["duration_ms"]
401 print(f" {icon} {gate['name']:<40} {ms:>8.0f} ms exit={gate['exit_code']}")
402 if not gate["passed"] and gate["stdout"]:
403 for line in gate["stdout"].strip().splitlines()[-5:]:
404 print(f" {line}")
405 if not gate["passed"] and gate["stderr"]:
406 for line in gate["stderr"].strip().splitlines()[-3:]:
407 print(f" {line}")
408 print("─" * width)
409 overall = "✅ PASSED" if result["passed"] else "❌ FAILED"
410 total_s = result["total_duration_ms"] / 1000.0
411 print(f" {overall} ({total_s:.1f} s total)")
412 print()
413
414
415 def _gate_to_json(g: GateResult) -> _CiGateJson:
416 """Serialise a single :class:`GateResult` for JSON output."""
417 out = _CiGateJson(
418 name=g["name"],
419 command=g["command"],
420 exit_code=g["exit_code"],
421 duration_ms=g["duration_ms"],
422 required=g["required"],
423 passed=g["passed"],
424 timed_out=g["timed_out"],
425 stdout=g["stdout"],
426 stderr=g["stderr"],
427 )
428 if "warning" in g:
429 out["warning"] = g["warning"]
430 return out
431
432
433 def _ci_to_json(result: CiRunResult) -> _CiJson:
434 """Serialise a :class:`CiRunResult` for JSON output."""
435 return _CiJson(
436 passed=result["passed"],
437 timestamp=result["timestamp"],
438 total_duration_ms=result["total_duration_ms"],
439 gates=[_gate_to_json(g) for g in result["gates"]],
440 )
441
442
443 # ---------------------------------------------------------------------------
444 # Helpers for run recording
445 # ---------------------------------------------------------------------------
446
447
448 def _run_result_to_record(
449 result: RunResult,
450 *,
451 commit_id: str | None,
452 branch: str | None,
453 selection: SelectionResult | None,
454 ) -> RunRecord:
455 """Convert a :class:`RunResult` to a persistable :class:`RunRecord`."""
456
457 def _to_case(r: CaseResult) -> CaseRecord:
458 # Determine which symbol addresses this test covers (from selection).
459 symbol_addresses: list[str] = []
460 if selection is not None:
461 for target in selection["test_targets"]:
462 if target["node_id"] == r["node_id"] or target["file"] in r["node_id"]:
463 symbol_addresses = list(selection["covered_addresses"])
464 break
465
466 rec = CaseRecord(
467 node_id=r["node_id"],
468 outcome=r["outcome"],
469 duration_ms=r["duration_ms"],
470 symbol_addresses=symbol_addresses,
471 )
472 if "longrepr" in r:
473 rec["longrepr"] = r["longrepr"]
474 return rec
475
476 return RunRecord(
477 run_id=result["run_id"],
478 timestamp=iso_now(),
479 commit_id=commit_id,
480 branch=branch,
481 results=[_to_case(r) for r in result["results"]],
482 total=result["total"],
483 passed=result["passed"],
484 failed=result["failed"],
485 errored=result["errored"],
486 skipped=result["skipped"],
487 )
488
489
490 # ---------------------------------------------------------------------------
491 # Main command handler
492 # ---------------------------------------------------------------------------
493
494
495 def run(args: argparse.Namespace) -> None:
496 """Dispatch the ``muse code test`` command."""
497 root = require_repo()
498 json_out = bool(args.json_output)
499
500 # ── History / flaky mode (read-only, no tests run) ───────────────────
501 if args.history or args.flaky:
502 records = load_history(root)
503 sums = summarize(records)
504 if args.flaky:
505 flaky_list = flaky_tests(records)
506 sums = {s["node_id"]: s for s in flaky_list}
507 if json_out:
508 out = _FullJson(
509 mode="history",
510 history=[_history_to_json(s) for s in sums.values()],
511 )
512 print(json.dumps(out, indent=2))
513 else:
514 _print_history(sums, flaky_only=args.flaky)
515 return
516
517 # ── CI mode ──────────────────────────────────────────────────────────
518 if args.ci:
519 try:
520 ci_config = load_ci_config(root)
521 except ValueError as exc:
522 _fatal(str(exc), json_out)
523 return
524 ci_result = run_ci(root, ci_config)
525 if json_out:
526 out = _FullJson(mode="ci", ci=_ci_to_json(ci_result))
527 print(json.dumps(out, indent=2))
528 else:
529 _print_ci_result(ci_result)
530 sys.exit(0 if ci_result["passed"] else 1)
531
532 # ── Determine what to run ─────────────────────────────────────────────
533 explicit_targets: list[str] = list(args.targets or [])
534 force_symbols: list[str] = list(args.symbols or [])
535 run_all: bool = bool(args.run_all)
536
537 # Try to load the HEAD snapshot for graph-based selection.
538 branch: str | None = None
539 commit_id: str | None = None
540 manifest: Manifest | None = None
541
542 try:
543 branch = read_current_branch(root)
544 commit_id = get_head_commit_id(root, branch)
545 if commit_id:
546 manifest = get_commit_snapshot_manifest(root, commit_id)
547 except Exception as exc:
548 logger.debug("test_cmd: could not load HEAD manifest: %s", exc)
549
550 selection: SelectionResult | None = None
551 final_targets: list[str]
552
553 if explicit_targets:
554 # User specified exact targets — run them directly.
555 final_targets = explicit_targets
556 elif run_all or manifest is None:
557 # No snapshot or --all flag — discover all tests.
558 final_targets = []
559 elif force_symbols:
560 # Force-select tests covering specific symbols.
561 forced_changed: list[ChangedSymbol] = [
562 ChangedSymbol(address=addr, change_kind="modified")
563 for addr in force_symbols
564 ]
565 selection = select_tests(
566 root,
567 forced_changed,
568 manifest,
569 depth=args.depth,
570 )
571 final_targets = [t["node_id"] for t in selection["test_targets"]]
572 else:
573 # Default: diff working tree vs HEAD and select covering tests.
574 try:
575 changed = changed_symbols_from_diff(root, manifest)
576 except Exception as exc:
577 logger.warning("⚠️ test_cmd: diff failed, falling back to --all: %s", exc)
578 changed = []
579
580 if not changed:
581 # Nothing changed in the working tree — there is nothing to test.
582 # Running the full suite here would silently block for minutes.
583 # Use --all to explicitly run every test file.
584 if json_out:
585 print(json.dumps({"mode": "run", "message": "no changes detected", "exit_code": 0}))
586 else:
587 print("\n✅ No changes detected — nothing to test.")
588 print(" Use --all to run the full suite explicitly.\n")
589 return
590 else:
591 selection = select_tests(root, changed, manifest, depth=args.depth)
592 final_targets = [t["node_id"] for t in selection["test_targets"]]
593
594 # Re-order targets using historical risk priority.
595 if final_targets:
596 records_for_priority = load_history(root)
597 final_targets = prioritize_targets(final_targets, records_for_priority)
598
599 # ── Dry-run ──────────────────────────────────────────────────────────
600 if args.dry_run:
601 _print_dry_run(selection, final_targets, json_out)
602 return
603
604 # ── Execute ──────────────────────────────────────────────────────────
605 extra: list[str] = list(args.extra or [])
606 config = RunConfig(
607 targets=final_targets,
608 workers=args.workers,
609 timeout_s=args.timeout,
610 extra_args=extra,
611 env_allowlist=[],
612 cwd=root,
613 stream_output=not json_out,
614 )
615
616 if not json_out:
617 _print_pre_run(selection, final_targets)
618
619 # When streaming, pytest writes directly to the terminal so progress_cb
620 # dots would interleave badly. Use progress_cb only in captured (json) mode.
621 result = run_tests(
622 config,
623 progress_cb=_progress_cb if json_out else None,
624 )
625
626 # ── Persist history ──────────────────────────────────────────────────
627 if not args.no_save:
628 record = _run_result_to_record(
629 result,
630 commit_id=commit_id,
631 branch=branch,
632 selection=selection,
633 )
634 try:
635 append_run(root, record)
636 except Exception as exc:
637 logger.warning("⚠️ test_cmd: failed to save history: %s", exc)
638
639 # ── Output ───────────────────────────────────────────────────────────
640 if json_out:
641 sel_json: _SelectionJson | None = None
642 if selection is not None:
643 sel_json = _SelectionJson(
644 changed_addresses=selection["changed_addresses"],
645 covered_addresses=selection["covered_addresses"],
646 uncovered_addresses=selection["uncovered_addresses"],
647 coverage_fraction=selection["coverage_fraction"],
648 fallback_used=selection["fallback_used"],
649 targets=[t["node_id"] for t in selection["test_targets"]],
650 )
651
652 run_json = _RunJson(
653 run_id=result["run_id"],
654 exit_code=result["exit_code"],
655 duration_ms=result["duration_ms"],
656 total=result["total"],
657 passed=result["passed"],
658 failed=result["failed"],
659 errored=result["errored"],
660 skipped=result["skipped"],
661 timed_out=result["timed_out"],
662 json_report_available=result["json_report_available"],
663 )
664
665 test_results: list[_TestResultJson] = []
666 for r in result["results"]:
667 tr = _TestResultJson(
668 node_id=r["node_id"],
669 outcome=r["outcome"],
670 duration_ms=r["duration_ms"],
671 )
672 if "longrepr" in r:
673 tr["longrepr"] = r["longrepr"]
674 test_results.append(tr)
675
676 out = _FullJson(mode="run", run=run_json, results=test_results)
677 if sel_json is not None:
678 out["selection"] = sel_json
679 print(json.dumps(out, indent=2))
680 else:
681 _print_summary(result, selection)
682
683 sys.exit(result["exit_code"] if result["exit_code"] in {0, 1} else 1)
684
685
686 # ---------------------------------------------------------------------------
687 # Display helpers
688 # ---------------------------------------------------------------------------
689
690
691 def _fatal(msg: str, json_out: bool) -> None:
692 if json_out:
693 print(json.dumps({"error": msg}))
694 else:
695 print(f"❌ {msg}", file=sys.stderr)
696 sys.exit(1)
697
698
699 def _progress_cb(result: CaseResult) -> None:
700 """Stream a single test result to stderr as it arrives.
701
702 Progress dots go to stderr so they never contaminate the JSON object
703 emitted to stdout in ``--json`` mode.
704 """
705 icon = {"passed": ".", "failed": "F", "error": "E", "skipped": "s"}.get(
706 result["outcome"], "?"
707 )
708 print(icon, end="", flush=True, file=sys.stderr)
709
710
711 def _print_pre_run(selection: SelectionResult | None, targets: list[str]) -> None:
712 """Print a pre-run summary before tests execute."""
713 if selection is not None:
714 n = len(selection["changed_addresses"])
715 t = len(targets)
716 uncov = len(selection["uncovered_addresses"])
717 pct = selection["coverage_fraction"] * 100
718 print(
719 f"\n🔍 Changed symbols: {n} → Selected tests: {t} "
720 f"(coverage {pct:.0f}%)"
721 )
722 if uncov:
723 print(f" ⚠️ {uncov} symbol(s) have no covering test:")
724 for addr in selection["uncovered_addresses"][:5]:
725 print(f" • {sanitize_display(addr)}")
726 if uncov > 5:
727 print(f" … and {uncov - 5} more")
728 if selection["fallback_used"]:
729 print(" ℹ️ File-name heuristics used for some targets (graph miss)")
730 elif targets:
731 print(f"\n🔍 Running {len(targets)} specified target(s)")
732 else:
733 print("\n🔍 Running full test suite (--all or no HEAD snapshot)")
734 print()
735
736
737 def _print_dry_run(
738 selection: SelectionResult | None,
739 targets: list[str],
740 json_out: bool,
741 ) -> None:
742 """Print the selected targets without executing them."""
743 if json_out:
744 sel_json: _SelectionJson | None = None
745 if selection is not None:
746 sel_json = _SelectionJson(
747 changed_addresses=selection["changed_addresses"],
748 covered_addresses=selection["covered_addresses"],
749 uncovered_addresses=selection["uncovered_addresses"],
750 coverage_fraction=selection["coverage_fraction"],
751 fallback_used=selection["fallback_used"],
752 targets=targets,
753 )
754 out = _FullJson(mode="dry-run")
755 if sel_json is not None:
756 out["selection"] = sel_json
757 print(json.dumps(out, indent=2))
758 return
759
760 if selection is not None:
761 _print_pre_run(selection, targets)
762
763 if targets:
764 print("Would run:")
765 for t in targets:
766 print(f" pytest {t}")
767 else:
768 print("Would run: pytest (full discovery)")
769
770
771 def _print_summary(result: RunResult, selection: SelectionResult | None) -> None:
772 """Print the post-run summary."""
773 print()
774 width = 60
775 print("─" * width)
776 icon = "✅" if result["exit_code"] == 0 else "❌"
777 s = result["duration_ms"] / 1000.0
778
779 counts_available = result["json_report_available"] or result["total"] > 0
780 if counts_available:
781 print(
782 f"{icon} {result['passed']} passed "
783 f"{result['failed']} failed "
784 f"{result['errored']} error "
785 f"{result['skipped']} skipped "
786 f"({s:.2f} s)"
787 )
788 else:
789 # Stream mode without pytest-json-report: pytest output went straight
790 # to the terminal but was never captured — counts are unavailable.
791 # The exit code is still correct.
792 print(f"{icon} counts unavailable ({s:.2f} s)")
793 print(
794 " ℹ️ Install pytest-json-report for structured counts: "
795 "pip install pytest-json-report"
796 )
797
798 if result["timed_out"]:
799 print(" ⚠️ Run was terminated due to timeout")
800
801 # Show any uncovered symbols as a reminder.
802 if selection is not None and selection["uncovered_addresses"]:
803 uncov = selection["uncovered_addresses"]
804 print(f"\n⚠️ Coverage gaps — {len(uncov)} changed symbol(s) have no tests:")
805 for addr in uncov[:10]:
806 print(f" • {sanitize_display(addr)}")
807 if len(uncov) > 10:
808 print(f" … and {len(uncov) - 10} more")
809 print()
File History 1 commit
sha256:0e3697f29ae6c62f39818a19861d4db6276465de16481fadf866eb93cbf4aae4 test: validate staging push and MP workflow for aaronrene Human 16 days ago