triage-flakes.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. #!/usr/bin/env python3
  2. # Rank Playwright tests by flakiness from a JSON report so the agent triages, not eyeballs.
  3. #
  4. # Parses a Playwright JSON report (`--reporter=json`) and surfaces the tests
  5. # worth a human's attention: flaky tests (passed only on retry) first, then
  6. # hard "unexpected" failures. Flaky tests are ranked by retry count desc, then
  7. # total duration desc, because the most-retried, slowest test is the worst
  8. # offender in your queue.
  9. #
  10. # Usage: triage-flakes.py [OPTIONS] [REPORT]
  11. # Input: REPORT = path to a Playwright JSON report (positional, default ./results.json)
  12. # Output: stdout = ranked findings (TSV, or JSON envelope with --json)
  13. # Stderr: headers, summary, progress, errors
  14. # Exit: 0 parsed fine, no flaky/unexpected tests (clean suite)
  15. # 2 usage, 3 file not found, 4 malformed/not a Playwright report,
  16. # 10 DOMAIN SIGNAL: flaky/unexpected tests present (the thing being triaged)
  17. #
  18. # Examples:
  19. # npx playwright test --reporter=json > results.json
  20. # triage-flakes.py results.json
  21. # triage-flakes.py --outcome all -n 50 results.json
  22. # triage-flakes.py --json results.json | jq '.data[] | select(.outcome=="flaky")'
  23. import argparse
  24. import json
  25. import sys
  26. from pathlib import Path
  27. SCHEMA = "claude-mods.playwright-ops.flake-triage/v1"
  28. EXIT_OK = 0
  29. EXIT_USAGE = 2
  30. EXIT_NOT_FOUND = 3
  31. EXIT_VALIDATION = 4
  32. EXIT_FINDINGS = 10
  33. # Rank order for outcomes: flaky always sorts before unexpected.
  34. OUTCOME_RANK = {"flaky": 0, "unexpected": 1}
  35. def err(msg):
  36. print(msg, file=sys.stderr)
  37. def walk_suites(suites, finds, file_hint=""):
  38. """Recursively descend the suites tree collecting spec/test results."""
  39. for suite in suites or []:
  40. # A suite's file is on the suite node; specs inherit it.
  41. sfile = suite.get("file") or file_hint
  42. for spec in suite.get("specs", []) or []:
  43. collect_spec(spec, finds, sfile)
  44. walk_suites(suite.get("suites"), finds, sfile)
  45. def collect_spec(spec, finds, sfile):
  46. title = spec.get("title", "<untitled>")
  47. sline = spec.get("line", 0)
  48. sfile = spec.get("file") or sfile
  49. for test in spec.get("tests", []) or []:
  50. outcome = test.get("status") or test.get("outcome") or "unknown"
  51. results = test.get("results", []) or []
  52. # status sequence ordered by retry index; duration summed across attempts
  53. ordered = sorted(results, key=lambda r: r.get("retry", 0))
  54. statuses = [r.get("status", "unknown") for r in ordered]
  55. duration = sum(int(r.get("duration", 0) or 0) for r in ordered)
  56. retries = max((r.get("retry", 0) for r in ordered), default=0)
  57. location = f"{sfile}:{sline}" if sfile else f"?:{sline}"
  58. finds.append(
  59. {
  60. "title": title,
  61. "location": location,
  62. "outcome": outcome,
  63. "retries": retries,
  64. "statuses": statuses,
  65. "durationMs": duration,
  66. }
  67. )
  68. def load_report(path):
  69. """Return parsed Playwright report dict, or raise ValueError if not one."""
  70. try:
  71. raw = path.read_text(encoding="utf-8")
  72. except OSError as e:
  73. raise FileNotFoundError(str(e))
  74. try:
  75. data = json.loads(raw)
  76. except json.JSONDecodeError as e:
  77. raise ValueError(f"not valid JSON: {e}")
  78. if not isinstance(data, dict) or "suites" not in data:
  79. raise ValueError("missing top-level 'suites' key - not a Playwright JSON report")
  80. if not isinstance(data["suites"], list):
  81. raise ValueError("'suites' is not a list — not a Playwright JSON report")
  82. return data
  83. def main(argv=None):
  84. p = argparse.ArgumentParser(
  85. prog="triage-flakes.py",
  86. description="Rank Playwright tests by flakiness from a JSON report.",
  87. formatter_class=argparse.RawDescriptionHelpFormatter,
  88. epilog=(
  89. "EXAMPLES:\n"
  90. " npx playwright test --reporter=json > results.json\n"
  91. " triage-flakes.py results.json\n"
  92. " triage-flakes.py --outcome all -n 50 results.json\n"
  93. " triage-flakes.py --json results.json | jq '.data[] | select(.outcome==\"flaky\")'\n"
  94. "\n"
  95. "EXIT CODES:\n"
  96. " 0 parsed fine, no flaky/unexpected tests (clean suite)\n"
  97. " 2 usage 3 file not found 4 malformed report\n"
  98. " 10 flaky/unexpected tests present (the triage signal)\n"
  99. ),
  100. )
  101. p.add_argument(
  102. "report",
  103. nargs="?",
  104. default="results.json",
  105. help="path to Playwright JSON report (default: ./results.json)",
  106. )
  107. p.add_argument("--json", action="store_true", help="emit a JSON envelope instead of TSV")
  108. p.add_argument("-q", "--quiet", action="store_true",
  109. help="suppress the stderr summary header (errors still print)")
  110. p.add_argument(
  111. "-n",
  112. "--limit",
  113. type=int,
  114. default=20,
  115. metavar="N",
  116. help="cap rows printed (default 20)",
  117. )
  118. p.add_argument(
  119. "--outcome",
  120. default="flaky,unexpected",
  121. help="which outcomes to include: flaky | unexpected | all (default flaky,unexpected)",
  122. )
  123. args = p.parse_args(argv)
  124. if args.limit < 0:
  125. err("ERROR: --limit must be >= 0")
  126. return EXIT_USAGE
  127. sel = args.outcome.strip().lower()
  128. if sel == "all":
  129. wanted = None # all outcomes
  130. else:
  131. wanted = {x.strip() for x in sel.split(",") if x.strip()}
  132. unknown = wanted - {"flaky", "unexpected", "expected", "skipped"}
  133. if unknown:
  134. err(f"ERROR: unknown outcome(s): {', '.join(sorted(unknown))} (use flaky|unexpected|all)")
  135. return EXIT_USAGE
  136. path = Path(args.report).resolve()
  137. if not path.exists():
  138. err(f"ERROR: report not found: {path}")
  139. if args.json:
  140. print(json.dumps({"error": {"code": "NOT_FOUND", "message": f"report not found: {path}"}}))
  141. return EXIT_NOT_FOUND
  142. if not path.is_file():
  143. err(f"ERROR: not a file: {path}")
  144. return EXIT_NOT_FOUND
  145. try:
  146. data = load_report(path)
  147. except FileNotFoundError as e:
  148. err(f"ERROR: cannot read report: {e}")
  149. return EXIT_NOT_FOUND
  150. except ValueError as e:
  151. err(f"ERROR: malformed report: {e}")
  152. if args.json:
  153. print(json.dumps({"error": {"code": "VALIDATION", "message": str(e)}}))
  154. return EXIT_VALIDATION
  155. finds = []
  156. walk_suites(data.get("suites"), finds)
  157. # The domain signal is computed over ALL findings, regardless of the display
  158. # filter — a clean suite means zero flaky AND zero unexpected, full stop.
  159. signal_present = any(f["outcome"] in ("flaky", "unexpected") for f in finds)
  160. if wanted is None:
  161. shown = list(finds)
  162. else:
  163. shown = [f for f in finds if f["outcome"] in wanted]
  164. # Rank: flaky before unexpected (OUTCOME_RANK), then retries desc, duration desc.
  165. shown.sort(
  166. key=lambda f: (
  167. OUTCOME_RANK.get(f["outcome"], 99),
  168. -f["retries"],
  169. -f["durationMs"],
  170. )
  171. )
  172. capped = shown[: args.limit] if args.limit else shown
  173. total = len(finds)
  174. flaky_n = sum(1 for f in finds if f["outcome"] == "flaky")
  175. unexp_n = sum(1 for f in finds if f["outcome"] == "unexpected")
  176. if not args.quiet:
  177. err(f"=== Flake triage: {path.name} ===")
  178. err(f" {total} tests | {flaky_n} flaky | {unexp_n} unexpected | showing {len(capped)} of {len(shown)}")
  179. if args.json:
  180. envelope = {
  181. "data": capped,
  182. "meta": {
  183. "count": len(capped),
  184. "total_matched": len(shown),
  185. "flaky": flaky_n,
  186. "unexpected": unexp_n,
  187. "schema": SCHEMA,
  188. },
  189. }
  190. print(json.dumps(envelope, indent=2))
  191. else:
  192. print("outcome\tretries\tstatuses\tduration_ms\tlocation\ttitle")
  193. for f in capped:
  194. print(
  195. f"{f['outcome']}\t{f['retries']}\t{'->'.join(f['statuses'])}\t"
  196. f"{f['durationMs']}\t{f['location']}\t{f['title']}"
  197. )
  198. return EXIT_FINDINGS if signal_present else EXIT_OK
  199. if __name__ == "__main__":
  200. sys.exit(main())