test_history.py python
513 lines 16.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """Persistent test-run history indexed by pytest node ID.
2
3 Every time ``muse code test`` executes a test suite it appends a
4 :class:`RunRecord` to the history. The history is stored in
5 ``.muse/test_history.msgpack`` — a msgpack-encoded list of run records,
6 one per ``run_tests`` invocation.
7
8 Why msgpack?
9 ------------
10 * **Consistency** — the rest of the Muse object store uses msgpack.
11 * **Performance** — msgpack encodes/decodes the history in <5 ms for
12 10,000 test-result records.
13 * **Correctness** — binary-safe, no ambiguous JSON quoting.
14
15 What the history enables
16 ------------------------
17 * **Flaky-test detection** — a test that sometimes passes and sometimes
18 fails across the last N runs is flagged as flaky.
19 * **Failure-streak tracking** — how many consecutive runs ended in failure
20 for a given test? High streaks signal systemic breakage.
21 * **Duration trend** — is a test getting slower over time?
22 * **Smart test ordering** — sort slowest/most-recently-failed tests to run
23 first so failures surface as early as possible in a parallel run.
24
25 Security
26 --------
27 The history file is written atomically (rename-after-write) to prevent
28 partial writes from corrupting the index. All data originates from pytest
29 subprocess output (JSON report); no user-supplied data is executed.
30 """
31
32 from __future__ import annotations
33
34 import logging
35 import os
36 import pathlib
37 import time
38 import uuid
39 from collections.abc import Sequence
40 from typing import Literal, NotRequired, TypedDict
41
42 import msgpack
43
44 from muse.core._types import MsgpackDict
45 from muse.core.store import MsgpackValue, _int_val, _str_list, _str_or_none, _str_val, read_msgpack_file
46
47 logger = logging.getLogger(__name__)
48
49 # ---------------------------------------------------------------------------
50 # Public type definitions
51 # ---------------------------------------------------------------------------
52
53 Outcome = Literal["passed", "failed", "error", "skipped"]
54
55
56 class CaseRecord(TypedDict):
57 """Result of a single test function within a run."""
58
59 node_id: str
60 """Pytest node ID, e.g. ``"tests/test_foo.py::TestBar::test_baz"``."""
61
62 outcome: Outcome
63 """Test outcome as reported by pytest."""
64
65 duration_ms: float
66 """Wall-clock execution time in milliseconds."""
67
68 symbol_addresses: list[str]
69 """Production symbol addresses this test is known to cover (may be empty
70 when selection did not produce coverage data)."""
71
72 longrepr: NotRequired[str]
73 """Short failure representation from pytest (omitted when passing)."""
74
75
76 class RunRecord(TypedDict):
77 """A single ``muse code test`` invocation."""
78
79 run_id: str
80 """UUID v4 string identifying this specific run."""
81
82 timestamp: str
83 """ISO 8601 UTC timestamp of the run start, e.g. ``"2026-03-26T14:05:00Z"``."""
84
85 commit_id: str | None
86 """HEAD commit ID at the time of the run, or ``None`` if repo has no commits."""
87
88 branch: str | None
89 """Current branch name at run time, or ``None`` for detached HEAD."""
90
91 results: list[CaseRecord]
92 """Individual test-case outcomes within this run."""
93
94 total: int
95 """Total number of test cases."""
96
97 passed: int
98 """Number of passing test cases."""
99
100 failed: int
101 """Number of failing test cases."""
102
103 errored: int
104 """Number of test cases that raised an unexpected error."""
105
106 skipped: int
107 """Number of skipped test cases."""
108
109
110 class HistorySummary(TypedDict):
111 """Per-test-function aggregated history summary."""
112
113 node_id: str
114 """Pytest node ID."""
115
116 total_runs: int
117 """Number of times this test has been seen across all recorded runs."""
118
119 pass_count: int
120 """Runs where the test passed."""
121
122 fail_count: int
123 """Runs where the test failed or errored."""
124
125 skip_count: int
126 """Runs where the test was skipped."""
127
128 flaky: bool
129 """True when pass_count > 0 **and** fail_count > 0 across recorded runs."""
130
131 avg_duration_ms: float
132 """Mean execution time across all non-skipped runs, in milliseconds."""
133
134 last_outcome: Outcome | None
135 """Most recent outcome for this test, or ``None`` if never recorded."""
136
137 last_run_timestamp: str | None
138 """ISO 8601 timestamp of the most recent run that included this test."""
139
140 fail_streak: int
141 """Number of consecutive most-recent runs in which the test failed/errored."""
142
143
144 # ---------------------------------------------------------------------------
145 # Storage path
146 # ---------------------------------------------------------------------------
147
148 _HISTORY_FILE = "test_history.msgpack"
149 _HISTORY_VERSION = 1
150
151 type _SummaryMap = dict[str, "HistorySummary"]
152 type _MutableSummaryMap = dict[str, "_MutableSummary"]
153
154
155 # ---------------------------------------------------------------------------
156 # Internal serialisation TypedDicts
157 # ---------------------------------------------------------------------------
158
159
160 class _TestCaseDoc(TypedDict):
161 """Msgpack document shape for a single test-case result."""
162
163 node_id: str
164 outcome: str
165 duration_ms: float
166 symbol_addresses: list[str]
167 longrepr: str
168
169
170 class _RunDoc(TypedDict):
171 """Msgpack document shape for a single run record."""
172
173 run_id: str
174 timestamp: str
175 commit_id: str | None
176 branch: str | None
177 total: int
178 passed: int
179 failed: int
180 errored: int
181 skipped: int
182 results: list[_TestCaseDoc]
183
184
185 class _HistoryDoc(TypedDict):
186 """Top-level msgpack document shape for the history file."""
187
188 version: int
189 runs: list[_RunDoc]
190
191
192 def _history_path(root: pathlib.Path) -> pathlib.Path:
193 """Return the path to the test-history msgpack file inside ``.muse/``."""
194 return root / ".muse" / _HISTORY_FILE
195
196
197 # ---------------------------------------------------------------------------
198 # Serialisation helpers
199 # ---------------------------------------------------------------------------
200
201
202 def _record_to_msgpack(record: RunRecord) -> _RunDoc:
203 """Serialise a :class:`RunRecord` to a msgpack-compatible :class:`_RunDoc`."""
204 return _RunDoc(
205 run_id=record["run_id"],
206 timestamp=record["timestamp"],
207 commit_id=record.get("commit_id"),
208 branch=record.get("branch"),
209 total=record["total"],
210 passed=record["passed"],
211 failed=record["failed"],
212 errored=record["errored"],
213 skipped=record["skipped"],
214 results=[
215 _TestCaseDoc(
216 node_id=r["node_id"],
217 outcome=r["outcome"],
218 duration_ms=r["duration_ms"],
219 symbol_addresses=r["symbol_addresses"],
220 longrepr=r.get("longrepr", ""),
221 )
222 for r in record["results"]
223 ],
224 )
225
226
227 def _record_from_msgpack(raw: MsgpackValue) -> RunRecord | None:
228 """Deserialise a msgpack-decoded value into a :class:`RunRecord`.
229
230 Returns ``None`` on any structural mismatch so a single corrupt entry
231 does not abort the entire history load.
232 """
233 if not isinstance(raw, dict):
234 logger.debug("test_history: skipping non-dict run record")
235 return None
236 try:
237 results: list[CaseRecord] = []
238 raw_results = raw.get("results", [])
239 if not isinstance(raw_results, list):
240 return None
241 for r in raw_results:
242 if not isinstance(r, dict):
243 continue
244 r_dict: MsgpackDict = r
245 node_id = _str_val(r_dict, "node_id", "")
246 raw_outcome = _str_val(r_dict, "outcome", "error")
247 if not node_id:
248 continue
249 if raw_outcome == "passed":
250 outcome: Outcome = "passed"
251 elif raw_outcome == "failed":
252 outcome = "failed"
253 elif raw_outcome == "skipped":
254 outcome = "skipped"
255 else:
256 outcome = "error"
257 longrepr = _str_val(r_dict, "longrepr", "")
258 duration_raw = r_dict.get("duration_ms", 0.0)
259 duration_ms = float(duration_raw) if isinstance(duration_raw, (int, float)) else 0.0
260 rec = CaseRecord(
261 node_id=node_id,
262 outcome=outcome,
263 duration_ms=duration_ms,
264 symbol_addresses=_str_list(r_dict, "symbol_addresses"),
265 )
266 if longrepr:
267 rec["longrepr"] = longrepr
268 results.append(rec)
269
270 raw_dict: MsgpackDict = raw
271
272 run_id_str = _str_val(raw_dict, "run_id", "") or str(uuid.uuid4())
273 timestamp_str = _str_val(raw_dict, "timestamp", "")
274 commit_id_str = _str_or_none(raw_dict, "commit_id")
275 branch_str = _str_or_none(raw_dict, "branch")
276
277 return RunRecord(
278 run_id=run_id_str,
279 timestamp=timestamp_str,
280 commit_id=commit_id_str,
281 branch=branch_str,
282 results=results,
283 total=_int_val(raw_dict, "total", len(results)),
284 passed=_int_val(raw_dict, "passed", 0),
285 failed=_int_val(raw_dict, "failed", 0),
286 errored=_int_val(raw_dict, "errored", 0),
287 skipped=_int_val(raw_dict, "skipped", 0),
288 )
289 except (KeyError, TypeError, ValueError) as exc:
290 logger.debug("test_history: failed to deserialise run record: %s", exc)
291 return None
292
293
294 # ---------------------------------------------------------------------------
295 # Public I/O
296 # ---------------------------------------------------------------------------
297
298
299 def load_history(root: pathlib.Path) -> list[RunRecord]:
300 """Load and return all run records from ``.muse/test_history.msgpack``.
301
302 Returns an empty list if the file does not exist or cannot be parsed.
303 Individual corrupt records are silently skipped so one bad entry never
304 prevents history from loading.
305 """
306 path = _history_path(root)
307 if not path.exists():
308 return []
309 try:
310 doc = read_msgpack_file(path)
311 except Exception as exc:
312 logger.warning("⚠️ test_history: could not load %s: %s", path, exc)
313 return []
314
315 if not isinstance(doc, dict):
316 return []
317
318 entries = doc.get("runs", [])
319 if not isinstance(entries, list):
320 return []
321
322 records: list[RunRecord] = []
323 for entry in entries:
324 parsed = _record_from_msgpack(entry)
325 if parsed is not None:
326 records.append(parsed)
327 return records
328
329
330 def save_history(root: pathlib.Path, records: list[RunRecord]) -> None:
331 """Atomically overwrite ``.muse/test_history.msgpack`` with *records*.
332
333 Uses rename-after-write to guarantee the file is never left in a
334 partially written state.
335 """
336 path = _history_path(root)
337 path.parent.mkdir(parents=True, exist_ok=True)
338
339 doc = _HistoryDoc(
340 version=_HISTORY_VERSION,
341 runs=[_record_to_msgpack(r) for r in records],
342 )
343 packed = msgpack.packb(doc, use_bin_type=True)
344
345 tmp = path.with_suffix(".tmp")
346 try:
347 tmp.write_bytes(packed)
348 os.replace(tmp, path)
349 except OSError as exc:
350 logger.error("❌ test_history: failed to write %s: %s", path, exc)
351 tmp.unlink(missing_ok=True)
352 raise
353
354
355 def append_run(root: pathlib.Path, record: RunRecord) -> None:
356 """Append a single :class:`RunRecord` to the history.
357
358 Loads the existing history, appends *record*, and saves atomically.
359 Concurrent appends from parallel workers may interleave; the history is
360 not a CRDT but the worst-case outcome is a duplicate entry which is
361 harmless for the analytics use-cases.
362 """
363 records = load_history(root)
364 records.append(record)
365 save_history(root, records)
366
367
368 def make_run_id() -> str:
369 """Return a fresh UUID v4 string to identify a new test run."""
370 return str(uuid.uuid4())
371
372
373 def iso_now() -> str:
374 """Return the current UTC time as an ISO 8601 string (seconds precision)."""
375 t = time.gmtime()
376 return (
377 f"{t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d}T"
378 f"{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}Z"
379 )
380
381
382 # ---------------------------------------------------------------------------
383 # Analytics
384 # ---------------------------------------------------------------------------
385
386
387 def summarize(records: Sequence[RunRecord]) -> _SummaryMap:
388 """Aggregate *records* into a per-test summary map.
389
390 Args:
391 records: Run records as returned by :func:`load_history`.
392
393 Returns:
394 Dict mapping pytest node ID → :class:`HistorySummary`.
395 """
396 summaries: _MutableSummaryMap = {}
397
398 for run in records:
399 for result in run["results"]:
400 nid = result["node_id"]
401 if nid not in summaries:
402 summaries[nid] = _MutableSummary(
403 node_id=nid,
404 outcomes=[],
405 durations=[],
406 timestamps=[],
407 )
408 summaries[nid]["outcomes"].append(result["outcome"])
409 summaries[nid]["durations"].append(result["duration_ms"])
410 summaries[nid]["timestamps"].append(run["timestamp"])
411
412 out: _SummaryMap = {}
413 for nid, ms in summaries.items():
414 outcomes = ms["outcomes"]
415 durations = ms["durations"]
416 timestamps = ms["timestamps"]
417
418 pass_count = sum(1 for o in outcomes if o == "passed")
419 fail_count = sum(1 for o in outcomes if o in {"failed", "error"})
420 skip_count = sum(1 for o in outcomes if o == "skipped")
421
422 non_skip_durations = [
423 d for d, o in zip(durations, outcomes) if o != "skipped"
424 ]
425 avg_ms = (
426 sum(non_skip_durations) / len(non_skip_durations)
427 if non_skip_durations
428 else 0.0
429 )
430
431 # Failure streak: count consecutive failures from the most recent run.
432 streak = 0
433 for o in reversed(outcomes):
434 if o in {"failed", "error"}:
435 streak += 1
436 else:
437 break
438
439 last_timestamp = timestamps[-1] if timestamps else None
440 last_outcome: Outcome | None = outcomes[-1] if outcomes else None
441
442 out[nid] = HistorySummary(
443 node_id=nid,
444 total_runs=len(outcomes),
445 pass_count=pass_count,
446 fail_count=fail_count,
447 skip_count=skip_count,
448 flaky=pass_count > 0 and fail_count > 0,
449 avg_duration_ms=avg_ms,
450 last_outcome=last_outcome,
451 last_run_timestamp=last_timestamp,
452 fail_streak=streak,
453 )
454
455 return out
456
457
458 def flaky_tests(records: Sequence[RunRecord]) -> list[HistorySummary]:
459 """Return :class:`HistorySummary` entries for tests that are flaky.
460
461 A test is flaky when it has both at least one pass and at least one
462 failure across the recorded history. Results are sorted by
463 ``fail_count`` descending so the most problematic tests appear first.
464 """
465 sums = summarize(records)
466 flaky = [s for s in sums.values() if s["flaky"]]
467 flaky.sort(key=lambda s: s["fail_count"], reverse=True)
468 return flaky
469
470
471 def prioritize_targets(
472 node_ids: list[str],
473 records: Sequence[RunRecord],
474 ) -> list[str]:
475 """Re-order *node_ids* so highest-risk tests run first.
476
477 Risk ordering (highest first):
478
479 1. Tests with a failure streak > 0 (currently broken).
480 2. Tests that are flaky (historically unreliable).
481 3. Tests that have never been recorded (unknown risk — run early).
482 4. Tests sorted by average duration descending (slow tests surface
483 failures earlier in a parallel run).
484
485 Returns the same node IDs in a new order.
486 """
487 sums = summarize(records)
488
489 def _sort_key(nid: str) -> tuple[int, int, float]:
490 s = sums.get(nid)
491 if s is None:
492 # Unknown: moderate priority between streaky and healthy.
493 return (1, 0, 0.0)
494 streak_score = 0 if s["fail_streak"] == 0 else 2
495 flaky_score = 1 if s["flaky"] else 0
496 # Negate duration so slower tests come first (we sort ascending).
497 return (-(streak_score + flaky_score), 0, -s["avg_duration_ms"])
498
499 return sorted(node_ids, key=_sort_key)
500
501
502 # ---------------------------------------------------------------------------
503 # Internal mutable accumulation type (not exported)
504 # ---------------------------------------------------------------------------
505
506
507 class _MutableSummary(TypedDict):
508 """Temporary accumulator used inside :func:`summarize`."""
509
510 node_id: str
511 outcomes: list[Outcome]
512 durations: list[float]
513 timestamps: list[str]
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago