testreporthub 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """TestReportHub — unified test reporting."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from testreporthub.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,61 @@
1
+ """Walk inputs, dispatch to parsers, aggregate into a TestRun."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Iterable, List, Optional
9
+
10
+ from testreporthub.models import TestRun
11
+ from testreporthub.parsers import DEFAULT_REGISTRY, ParseError
12
+ from testreporthub.parsers.base import BaseParser
13
+
14
+
15
+ def _iter_files(paths: Iterable[Path]) -> List[Path]:
16
+ files: List[Path] = []
17
+ for p in paths:
18
+ if p.is_file():
19
+ files.append(p)
20
+ elif p.is_dir():
21
+ files.extend(sorted(p.rglob("*")))
22
+ return [f for f in files if f.is_file()]
23
+
24
+
25
+ def collect(
26
+ inputs: Iterable[str],
27
+ format_name: Optional[str] = None,
28
+ run_id: Optional[str] = None,
29
+ ) -> TestRun:
30
+ """Parse every input and return a unified TestRun.
31
+
32
+ If `format_name` is set, every file is parsed with that parser
33
+ (errors are fatal). Otherwise we auto-detect per file and silently
34
+ skip files no parser claims.
35
+ """
36
+ paths = [Path(p) for p in inputs]
37
+ files = _iter_files(paths)
38
+
39
+ results = []
40
+ skipped: List[Path] = []
41
+
42
+ for file in files:
43
+ parser: Optional[BaseParser]
44
+ if format_name:
45
+ parser = DEFAULT_REGISTRY.get(format_name)
46
+ else:
47
+ parser = DEFAULT_REGISTRY.detect(file)
48
+ if parser is None:
49
+ skipped.append(file)
50
+ continue
51
+ try:
52
+ results.extend(parser.parse_file(file))
53
+ except ParseError:
54
+ # Auto-detected parsers shouldn't crash the whole run.
55
+ skipped.append(file)
56
+
57
+ return TestRun(
58
+ generated_at=datetime.utcnow(),
59
+ run_id=run_id or f"trh-{uuid.uuid4().hex[:8]}",
60
+ results=results,
61
+ )
testreporthub/cli.py ADDED
@@ -0,0 +1,343 @@
1
+ """CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ import click
11
+
12
+ from testreporthub import __version__
13
+ from testreporthub.aggregator import collect
14
+ from testreporthub.db import (
15
+ compare_runs,
16
+ find_flaky_tests,
17
+ get_run_trends,
18
+ list_runs,
19
+ quality_gate_check,
20
+ save_run,
21
+ )
22
+ from testreporthub.parsers import DEFAULT_REGISTRY
23
+ from testreporthub.reporter import render
24
+
25
+
26
+ def _parse_labels(raw: Tuple[str, ...]) -> Dict[str, str]:
27
+ """Parse --label key=value pairs into a dict."""
28
+ labels: Dict[str, str] = {}
29
+ for item in raw:
30
+ if "=" not in item:
31
+ raise click.BadParameter(f"Label must be key=value, got: {item!r}")
32
+ k, v = item.split("=", 1)
33
+ labels[k.strip()] = v.strip()
34
+ return labels
35
+
36
+
37
+ @click.group()
38
+ @click.version_option(__version__, prog_name="testreporthub")
39
+ def main() -> None:
40
+ """TestReportHub — one report, every framework."""
41
+
42
+
43
+ # ── collect ──────────────────────────────────────────────────────────────
44
+
45
+ @main.command()
46
+ @click.argument("inputs", nargs=-1, required=True, type=click.Path(exists=True))
47
+ @click.option("-o", "--output", "output_path", default="report.html", show_default=True,
48
+ type=click.Path(), help="Output file path.")
49
+ @click.option("--format", "output_format", default=None,
50
+ type=click.Choice(["html", "json", "ndjson"], case_sensitive=False),
51
+ help="Output format. Defaults to 'html' if .html, else 'json'.")
52
+ @click.option("--parser", "parser_format", default=None,
53
+ type=click.Choice(DEFAULT_REGISTRY.names(), case_sensitive=False),
54
+ help="Force a specific input parser.")
55
+ @click.option("--run-id", "run_id", default=None, help="Optional run identifier.")
56
+ @click.option("--quiet", is_flag=True, help="Suppress summary output.")
57
+ @click.option("--fail-on-failed", is_flag=True, help="Exit 1 if any tests failed.")
58
+ @click.option("--fail-on-error", is_flag=True, help="Exit 1 if any tests errored.")
59
+ def collect_cmd(
60
+ inputs: List[str],
61
+ output_path: str,
62
+ output_format: Optional[str],
63
+ parser_format: Optional[str],
64
+ run_id: Optional[str],
65
+ quiet: bool,
66
+ fail_on_failed: bool,
67
+ fail_on_error: bool,
68
+ ) -> None:
69
+ """Collect test reports from INPUTS and write a unified report."""
70
+ if not output_format:
71
+ ext = Path(output_path).suffix.lower()
72
+ output_format = "json" if ext == ".json" else "html"
73
+
74
+ run = collect(inputs, format_name=parser_format, run_id=run_id)
75
+ out_path = Path(output_path)
76
+ out_path.parent.mkdir(parents=True, exist_ok=True)
77
+
78
+ if output_format == "html":
79
+ render(run, out_path)
80
+ elif output_format == "json":
81
+ out_path.write_text(run.model_dump_json(indent=2), encoding="utf-8")
82
+ elif output_format == "ndjson":
83
+ with out_path.open("w", encoding="utf-8") as f:
84
+ for r in run.results:
85
+ f.write(r.model_dump_json() + "\n")
86
+
87
+ if not quiet:
88
+ click.echo(f"TestReportHub • run {run.run_id}")
89
+ click.echo(f" total={run.total} passed={len(run.passed)} "
90
+ f"failed={len(run.failed)} error={len(run.errored)} "
91
+ f"skipped={len(run.skipped)}")
92
+ click.echo(f" frameworks: {', '.join(run.frameworks) or '—'}")
93
+ click.echo(f" wrote: {out_path.resolve()}")
94
+
95
+ exit_code = 0
96
+ if fail_on_failed and len(run.failed) > 0:
97
+ click.echo("❌ Failing CI: Found failed tests.", err=True)
98
+ exit_code = 1
99
+ elif fail_on_error and len(run.errored) > 0:
100
+ click.echo("❌ Failing CI: Found errored tests.", err=True)
101
+ exit_code = 1
102
+
103
+ sys.exit(exit_code)
104
+
105
+
106
+ # ── formats ──────────────────────────────────────────────────────────────
107
+
108
+ @main.command()
109
+ def formats() -> None:
110
+ """List supported input formats."""
111
+ for name in DEFAULT_REGISTRY.names():
112
+ click.echo(f" {name}")
113
+
114
+
115
+ # ── ingest ───────────────────────────────────────────────────────────────
116
+
117
+ @main.command()
118
+ @click.argument("inputs", nargs=-1, required=True, type=click.Path(exists=True))
119
+ @click.option("--db", "db_path", default="testpulse.db", show_default=True,
120
+ type=click.Path(dir_okay=False), help="Path to the local SQLite database.")
121
+ @click.option("--parser", "parser_format", default=None,
122
+ type=click.Choice(DEFAULT_REGISTRY.names(), case_sensitive=False),
123
+ help="Force a specific input parser.")
124
+ @click.option("--run-id", "run_id", default=None, help="Optional run identifier.")
125
+ @click.option("--label", "raw_labels", multiple=True,
126
+ help="Attach a label (key=value). Repeatable. E.g. --label app=checkout --label env=staging")
127
+ def ingest(
128
+ inputs: List[str],
129
+ db_path: str,
130
+ parser_format: Optional[str],
131
+ run_id: Optional[str],
132
+ raw_labels: Tuple[str, ...],
133
+ ) -> None:
134
+ """Ingest reports and save to the local TestPulse SQLite database."""
135
+ labels = _parse_labels(raw_labels) if raw_labels else None
136
+ run = collect(inputs, format_name=parser_format, run_id=run_id)
137
+
138
+ try:
139
+ save_run(Path(db_path), run, labels=labels)
140
+ click.echo(f"✅ Successfully ingested {run.total} results into {db_path}")
141
+ click.echo(f" Run ID: {run.run_id}")
142
+ if labels:
143
+ lbl_str = ", ".join(f"{k}={v}" for k, v in labels.items())
144
+ click.echo(f" Labels: {lbl_str}")
145
+ except Exception as e:
146
+ click.echo(f"❌ Database error: {e}", err=True)
147
+ sys.exit(1)
148
+
149
+
150
+ # ── flaky ────────────────────────────────────────────────────────────────
151
+
152
+ @main.command()
153
+ @click.option("--db", "db_path", default="testpulse.db", show_default=True,
154
+ type=click.Path(exists=True, dir_okay=False))
155
+ @click.option("--limit", default=20, show_default=True)
156
+ @click.option("--label", "raw_labels", multiple=True,
157
+ help="Filter by label (key=value). Repeatable.")
158
+ def flaky(db_path: str, limit: int, raw_labels: Tuple[str, ...]) -> None:
159
+ """Identify flaky tests based on historical runs."""
160
+ path = Path(db_path)
161
+ labels = _parse_labels(raw_labels) if raw_labels else None
162
+
163
+ click.echo(f"🔍 Analyzing flaky tests in {db_path}...\n")
164
+ flaky_tests = find_flaky_tests(path, limit=limit, labels=labels)
165
+
166
+ if not flaky_tests:
167
+ click.echo("🎉 No flaky tests detected! Your suite is stable.")
168
+ return
169
+
170
+ click.echo(f"{'Test Name':<50} | {'Framework':<12} | {'Runs':<6} | {'Pass':<6} | {'Fail':<6} | {'Flake %'}")
171
+ click.echo("-" * 105)
172
+
173
+ for t in flaky_tests:
174
+ flake_rate = (t["fail_count"] / t["total_runs"]) * 100
175
+ name = t["full_name"][:47] + "..." if len(t["full_name"]) > 50 else t["full_name"]
176
+ click.echo(
177
+ f"{name:<50} | {t['framework']:<12} | {t['total_runs']:<6} | "
178
+ f"{t['pass_count']:<6} | {t['fail_count']:<6} | {flake_rate:.1f}%"
179
+ )
180
+
181
+
182
+ # ── runs ─────────────────────────────────────────────────────────────────
183
+
184
+ @main.command()
185
+ @click.option("--db", "db_path", default="testpulse.db", show_default=True,
186
+ type=click.Path(exists=True, dir_okay=False))
187
+ @click.option("--limit", default=20, show_default=True)
188
+ @click.option("--label", "raw_labels", multiple=True,
189
+ help="Filter by label (key=value). Repeatable.")
190
+ def runs(db_path: str, limit: int, raw_labels: Tuple[str, ...]) -> None:
191
+ """List recent run IDs."""
192
+ path = Path(db_path)
193
+ labels = _parse_labels(raw_labels) if raw_labels else None
194
+
195
+ for r in list_runs(path, limit=limit, labels=labels):
196
+ ts = r["generated_at"][:19].replace("T", " ")
197
+ lbl_str = ""
198
+ if r.get("labels"):
199
+ lbl_str = " " + ", ".join(f"{k}={v}" for k, v in r["labels"].items())
200
+ click.echo(
201
+ f"{r['run_id']:<20} {ts} "
202
+ f"total={r['total']} pass={r['passed']} fail={r['failed']}{lbl_str}"
203
+ )
204
+
205
+
206
+ # ── compare ──────────────────────────────────────────────────────────────
207
+
208
+ @main.command()
209
+ @click.option("--db", "db_path", default="testpulse.db", show_default=True,
210
+ type=click.Path(exists=True, dir_okay=False))
211
+ @click.option("--run-a", required=True, help="Baseline run ID.")
212
+ @click.option("--run-b", required=True, help="Current run ID.")
213
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON.")
214
+ def compare(db_path: str, run_a: str, run_b: str, as_json: bool) -> None:
215
+ """Compare two runs and surface regressions, fixes, and new failures."""
216
+ path = Path(db_path)
217
+ result = compare_runs(path, run_a, run_b)
218
+
219
+ if as_json:
220
+ click.echo(json.dumps(result, indent=2, default=str))
221
+ return
222
+
223
+ click.echo(f"📊 Comparing {run_a} → {run_b}\n")
224
+
225
+ def _section(title: str, items: list, color: str) -> None:
226
+ if not items:
227
+ return
228
+ click.echo(click.style(f"\n{title} ({len(items)})", fg=color, bold=True))
229
+ click.echo("-" * 80)
230
+ for item in items:
231
+ if "before" in item:
232
+ test = item["after"]
233
+ else:
234
+ test = item
235
+ click.echo(f" • {test['full_name']:<60} [{test['framework']}]")
236
+
237
+ _section("🔴 REGRESSIONS (passed → failed)", result["regressions"], "red")
238
+ _section("🟡 NEW FAILURES (didn't exist before)", result["new_failures"], "yellow")
239
+ _section("🟢 FIXES (failed → passed)", result["fixes"], "green")
240
+ _section("🔵 NEW PASSES (didn't exist before)", result["new_passes"], "blue")
241
+ _section("⚫ STILL FAILING", result["still_failing"], "white")
242
+
243
+ click.echo("\n" + "=" * 80)
244
+ click.echo(
245
+ f"Summary: {len(result['regressions'])} regressions, "
246
+ f"{len(result['fixes'])} fixes, "
247
+ f"{len(result['new_failures'])} new failures, "
248
+ f"{len(result['still_failing'])} still failing"
249
+ )
250
+
251
+
252
+ # ── history ──────────────────────────────────────────────────────────────
253
+
254
+ @main.command()
255
+ @click.option("--db", "db_path", default="testpulse.db", show_default=True,
256
+ type=click.Path(exists=True, dir_okay=False))
257
+ @click.option("--limit", default=20, show_default=True)
258
+ @click.option("--label", "raw_labels", multiple=True,
259
+ help="Filter by label (key=value). Repeatable.")
260
+ def history(db_path: str, limit: int, raw_labels: Tuple[str, ...]) -> None:
261
+ """Show trends across recent runs."""
262
+ path = Path(db_path)
263
+ labels = _parse_labels(raw_labels) if raw_labels else None
264
+ trends = get_run_trends(path, limit=limit, labels=labels)
265
+
266
+ if not trends:
267
+ click.echo("No runs found. Run `trh ingest` first.")
268
+ return
269
+
270
+ click.echo(f"📈 Run history (last {len(trends)} runs)\n")
271
+ click.echo(f"{'Run ID':<20} | {'Time':<20} | {'Total':<6} | {'Pass':<6} | {'Fail':<6} | {'Pass %'}")
272
+ click.echo("-" * 90)
273
+
274
+ for t in trends:
275
+ ts = t["generated_at"][:19].replace("T", " ")
276
+ click.echo(
277
+ f"{t['run_id']:<20} | {ts:<20} | {t['total']:<6} | "
278
+ f"{t['passed']:<6} | {t['failed']:<6} | {t['pass_rate']:.1f}%"
279
+ )
280
+
281
+ totals = [t["total"] for t in trends]
282
+ pass_rates = [t["pass_rate"] for t in trends]
283
+ avg_pass = sum(pass_rates) / len(pass_rates)
284
+ click.echo("\n" + "=" * 90)
285
+ click.echo(
286
+ f"Avg pass rate: {avg_pass:.1f}% | "
287
+ f"Runs: {len(trends)} | "
288
+ f"Avg tests/run: {sum(totals) / len(totals):.0f}"
289
+ )
290
+
291
+
292
+ # ── quality-gate ─────────────────────────────────────────────────────────
293
+
294
+ @main.command("quality-gate")
295
+ @click.option("--db", "db_path", default="testpulse.db", show_default=True,
296
+ type=click.Path(exists=True, dir_okay=False))
297
+ @click.option("--label", "raw_labels", multiple=True,
298
+ help="Scope the gate to specific labels (key=value). Repeatable.")
299
+ @click.option("--min-pass-rate", default=95.0, show_default=True, type=float,
300
+ help="Minimum pass rate (%) for the latest run.")
301
+ @click.option("--max-flake-rate", default=5.0, show_default=True, type=float,
302
+ help="Maximum acceptable flaky test rate (%).")
303
+ @click.option("--max-regressions", default=0, show_default=True, type=int,
304
+ help="Maximum allowed regressions vs previous run.")
305
+ @click.option("--lookback", default=5, show_default=True, type=int,
306
+ help="Number of recent runs to consider.")
307
+ @click.option("--fail-on-breach", is_flag=True,
308
+ help="Exit with code 1 if any gate check fails.")
309
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON.")
310
+ def quality_gate(
311
+ db_path: str,
312
+ raw_labels: Tuple[str, ...],
313
+ min_pass_rate: float,
314
+ max_flake_rate: float,
315
+ max_regressions: int,
316
+ lookback: int,
317
+ fail_on_breach: bool,
318
+ as_json: bool,
319
+ ) -> None:
320
+ """Evaluate quality gates for runs matching the given labels."""
321
+ path = Path(db_path)
322
+ labels = _parse_labels(raw_labels) if raw_labels else None
323
+
324
+ result = quality_gate_check(
325
+ path,
326
+ labels=labels,
327
+ min_pass_rate=min_pass_rate,
328
+ max_flake_rate=max_flake_rate,
329
+ max_regressions=max_regressions,
330
+ lookback=lookback,
331
+ )
332
+
333
+ if as_json:
334
+ click.echo(json.dumps(result, indent=2, default=str))
335
+ else:
336
+ click.echo(result["summary"])
337
+
338
+ if fail_on_breach and not result["passed"]:
339
+ sys.exit(1)
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()