#!/usr/bin/env python3 """Render a per-spec table from a Playwright JSON report for a Gitea job summary (#136). Reads the JSON report (default: tests/e2e/playwright-report.json) that run-e2e-check.sh copies out of the e2e container, and prints a markdown table (one row per spec) to stdout. The CI step redirects it into $GITHUB_STEP_SUMMARY. Stdlib only. """ import json import os import re import sys STATUS_ICON = {"expected": "✅", "unexpected": "❌", "skipped": "⏭️", "flaky": "⚠️"} # A verdict alone still costs a log dive, and a killed or truncated job leaves no log to dive into # (#161) — so a failing spec carries its first error into the table. Playwright errors are multi-line # with a "Call log:", which a markdown table cell cannot hold, so they are flattened and clipped. ERROR_CLIP = 300 def first_error(spec): """The first error message across a spec's test results, flattened for one table cell.""" for test in spec.get("tests", []): for result in test.get("results", []): for error in result.get("errors", []): message = (error.get("message") or "").strip() if not message: continue # Strip ANSI colour, collapse to one line, and keep it inside the cell. message = re.sub(r"\x1b\[[0-9;]*m", "", message) message = " ".join(message.split()) if len(message) > ERROR_CLIP: message = message[:ERROR_CLIP - 1].rstrip() + "…" # `|` would end the cell early. return message.replace("|", "\\|") return "" def walk(suite, out): for spec in suite.get("specs", []): # A spec's status is carried on its test(s): expected/unexpected/skipped/flaky. statuses = [t.get("status") for t in spec.get("tests", [])] status = ("unexpected" if "unexpected" in statuses else "flaky" if "flaky" in statuses else "skipped" if statuses and all(s == "skipped" for s in statuses) else "expected" if spec.get("ok", False) else "unexpected") out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""), "title": spec.get("title", ""), "status": status, "error": first_error(spec) if status in ("unexpected", "flaky") else ""}) for child in suite.get("suites", []): walk(child, out) def main(path): if not os.path.exists(path): print("## 🎭 e2e (Playwright)\n\n_No e2e report — the run did not reach the e2e step._") return 0 with open(path) as fh: report = json.load(fh) specs = [] for suite in report.get("suites", []): walk(suite, specs) print("## 🎭 e2e (Playwright)\n") stats = report.get("stats", {}) if stats: print(f"**{stats.get('expected', 0)} passed · {stats.get('unexpected', 0)} failed · " f"{stats.get('flaky', 0)} flaky · {stats.get('skipped', 0)} skipped** " f"({round(stats.get('duration', 0) / 1000)}s)\n") if not specs: print("_No specs ran._") return 0 # The failure column only earns its width when something failed. if any(s["error"] for s in specs): print("| Spec | Result | Why |") print("| ---- | :----: | --- |") for s in specs: print(f"| {s['file']} › {s['title']} | {STATUS_ICON.get(s['status'], '❔')} | {s['error']} |") else: print("| Spec | Result |") print("| ---- | :----: |") for s in specs: print(f"| {s['file']} › {s['title']} | {STATUS_ICON.get(s['status'], '❔')} |") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "tests/e2e/playwright-report.json"))