mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
The swarm deploy job now renders the same summary page as compose: role runtimes, a swarm structure section (topology roles plus live node list and service placement from the manager) and the Playwright table. The matrix deploy tees its ansible output and prints the compose matrix-deploy round/PASS markers, so the runtime parser segments swarm runs per variant round too. The runtime table unifies tasks and roles with a Type column (tasks first), appends the recap total row and flags a mismatch between the parsed sum and the recap total; rows are read from ROLES RECAP sections only, which removes the double-counted TASKS RECAP task rows. Each role row carries one column per host: executed, skipped or failed, with ignored failures downgraded. The Playwright variant column drops the redundant variant- prefix. Verified against the logs of runs 29656268771 (swarm) and 29620158441 (compose): every segment's parsed sum matches the recap total within 0.05s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
8.1 KiB
Python
242 lines
8.1 KiB
Python
"""Render Playwright JUnit XML reports as GitHub Actions step-summary markdown.
|
|
|
|
Walks the given directory, parses every ``playwright-junit.xml`` it finds,
|
|
and prints a markdown table with one row per test case (app, test title,
|
|
status, message) to stdout. The output is designed to be appended to
|
|
``$GITHUB_STEP_SUMMARY``.
|
|
|
|
Usage:
|
|
python3 utils/github/playwright_summary.py <reports-dir> [<context-label>] # nocheck: self-path-reference
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import datetime
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
# Cap the message column at this length so a multi-kilobyte stack trace
|
|
# does not break the step-summary layout.
|
|
_MAX_MSG_LEN = 200
|
|
|
|
# Status indicators as requested: red / green / blue circles.
|
|
_STATUS_PASSED = "passed"
|
|
_STATUS_FAILED = "failed"
|
|
_STATUS_SKIPPED = "skipped"
|
|
_STATUS_EMOJI: dict[str, str] = {
|
|
_STATUS_PASSED: "🟢",
|
|
_STATUS_FAILED: "🔴",
|
|
_STATUS_SKIPPED: "🔵",
|
|
}
|
|
|
|
|
|
def _to_iso_z(dt: datetime.datetime) -> str:
|
|
"""Format ``dt`` as ``YYYY-MM-DDTHH:MM:SSZ`` (UTC, second precision)."""
|
|
return dt.astimezone(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _parse_iso_timestamp(value: str | None) -> datetime.datetime | None:
|
|
"""Parse a JUnit ``timestamp`` attribute. Treat tz-naive values as UTC.
|
|
|
|
Returns ``None`` when the value is empty or unparseable so callers
|
|
can fall back to other sources.
|
|
"""
|
|
if not value:
|
|
return None
|
|
try:
|
|
dt = datetime.datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=datetime.UTC)
|
|
return dt
|
|
|
|
|
|
def _file_mtime_utc(path: Path) -> datetime.datetime:
|
|
"""Last-modified time of *path* as a UTC ``datetime``."""
|
|
return datetime.datetime.fromtimestamp(path.stat().st_mtime, tz=datetime.UTC)
|
|
|
|
|
|
def _format_duration(seconds: float) -> str:
|
|
"""Format ``seconds`` as ``0.3s`` / ``12.5s`` / ``2m 15s``.
|
|
|
|
Sub-minute durations keep one decimal because many playwright tests
|
|
run in well under a second; rounding them all to ``0s`` / ``1s``
|
|
would hide useful per-test signal.
|
|
"""
|
|
if seconds < 60:
|
|
return f"{seconds:.1f}s"
|
|
m = int(seconds // 60)
|
|
s = round(seconds % 60)
|
|
return f"{m}m {s}s"
|
|
|
|
|
|
def _first_line(value: str | None) -> str:
|
|
if not value:
|
|
return ""
|
|
text = value.strip()
|
|
if not text:
|
|
return ""
|
|
return text.splitlines()[0]
|
|
|
|
|
|
def _md_escape_cell(value: str) -> str:
|
|
"""Markdown-table-cell safe: escape pipes and collapse newlines."""
|
|
return value.replace("|", "\\|").replace("\n", " ")
|
|
|
|
|
|
def _extract_records(cases: list[ET.Element]) -> list[tuple[str, str, str, float]]:
|
|
"""Return a ``(test_name, status, message, duration)`` tuple per testcase.
|
|
|
|
Status is one of ``passed`` / ``failed`` / ``skipped``. Message is
|
|
the first logical line of the JUnit ``<failure>`` / ``<error>`` /
|
|
``<skipped>`` element when present, else the empty string. Duration
|
|
is the value of the JUnit ``time`` attribute in seconds; missing or
|
|
unparseable values fall back to ``0.0``.
|
|
"""
|
|
records: list[tuple[str, str, str, float]] = []
|
|
for case in cases:
|
|
name = case.get("name", "?")
|
|
duration = 0.0
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
duration = float(case.get("time", "0") or "0")
|
|
fail = case.find("failure")
|
|
if fail is None:
|
|
fail = case.find("error")
|
|
if fail is not None:
|
|
msg = _first_line(fail.get("message") or fail.text)
|
|
records.append((name, _STATUS_FAILED, msg, duration))
|
|
continue
|
|
skip = case.find("skipped")
|
|
if skip is not None:
|
|
msg = _first_line(skip.get("message") or skip.text)
|
|
records.append((name, _STATUS_SKIPPED, msg, duration))
|
|
continue
|
|
records.append((name, _STATUS_PASSED, "", duration))
|
|
return records
|
|
|
|
|
|
_PASS_NAMES = ("sync", "async")
|
|
|
|
|
|
def _path_info(path: Path) -> tuple[str, str, str]:
|
|
"""Extract ``(app, variant, pass)`` from the JUnit file's location.
|
|
|
|
The role archives each run into
|
|
``<app_id>/variant-<N>/<sync|async>/playwright-junit.xml``. Legacy
|
|
layouts without the variant or pass nesting fall back to empty
|
|
strings for the missing dimensions so the renderer can still emit
|
|
a row.
|
|
"""
|
|
parent = path.parent
|
|
grand = parent.parent
|
|
if parent.name in _PASS_NAMES and grand.name.startswith("variant-"):
|
|
return (
|
|
grand.parent.name,
|
|
grand.name.removeprefix("variant-"),
|
|
parent.name,
|
|
)
|
|
if parent.name.startswith("variant-"):
|
|
return grand.name, parent.name.removeprefix("variant-"), ""
|
|
return parent.name, "", ""
|
|
|
|
|
|
def _summarize_file(path: Path) -> dict | None:
|
|
try:
|
|
root = ET.parse(path).getroot() # noqa: S314
|
|
except ET.ParseError:
|
|
return None
|
|
fallback_ts = _file_mtime_utc(path)
|
|
enriched: list[tuple[str, str, str, str, float]] = []
|
|
for suite in root.findall(".//testsuite"):
|
|
suite_ts = _parse_iso_timestamp(suite.get("timestamp", "")) or fallback_ts
|
|
cases = suite.findall("testcase")
|
|
base_records = _extract_records(cases)
|
|
offset_seconds = 0.0
|
|
for name, status, msg, duration in base_records:
|
|
case_start = suite_ts + datetime.timedelta(seconds=offset_seconds)
|
|
enriched.append((_to_iso_z(case_start), name, status, msg, duration))
|
|
offset_seconds += duration
|
|
if not enriched:
|
|
return None
|
|
app, variant, pass_ = _path_info(path)
|
|
return {
|
|
"app": app,
|
|
"variant": variant,
|
|
"pass": pass_,
|
|
"records": enriched,
|
|
}
|
|
|
|
|
|
def _render(summaries: list[dict], context: str) -> str:
|
|
title = f"### 🎭 Playwright — {context}" if context else "### 🎭 Playwright"
|
|
lines: list[str] = [
|
|
title,
|
|
"",
|
|
"Status: 🟢 passed · 🔴 failed · 🔵 skipped",
|
|
"",
|
|
"| Time | App | Variant | Pass | Test | Status | Duration | Message |",
|
|
"|---|---|---|---|---|:---:|---:|---|",
|
|
]
|
|
rows: list[tuple[str, str, str, str, str, str, str, float]] = [
|
|
(time_iso, s["app"], s["variant"], s["pass"], name, status, msg, duration)
|
|
for s in summaries
|
|
for time_iso, name, status, msg, duration in s["records"]
|
|
]
|
|
rows.sort(key=lambda r: r[0])
|
|
for time_iso, app, variant, pass_, name, status, msg, duration in rows:
|
|
emoji = _STATUS_EMOJI.get(status, "❔")
|
|
if msg:
|
|
truncated = msg[:_MAX_MSG_LEN] + ("…" if len(msg) > _MAX_MSG_LEN else "")
|
|
message_cell = _md_escape_cell(truncated)
|
|
else:
|
|
message_cell = ""
|
|
lines.append(
|
|
f"| `{time_iso}` | `{app}` | {variant} | {pass_} | "
|
|
f"{_md_escape_cell(name)} | {emoji} | "
|
|
f"{_format_duration(duration)} | {message_cell} |"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
if len(sys.argv) < 2:
|
|
sys.stderr.write(f"Usage: {sys.argv[0]} <reports-dir> [<context-label>]\n")
|
|
return 2
|
|
base = Path(sys.argv[1])
|
|
context = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
|
|
if not base.is_dir():
|
|
sys.stdout.write(
|
|
f"### 🎭 Playwright — {context or 'no reports'}\n\n"
|
|
f"_Reports directory `{base}` does not exist; Playwright did not run._\n"
|
|
)
|
|
return 0
|
|
|
|
# nocheck: project-walk — walks a CI artifact directory at runtime,
|
|
# not the project tree; the `utils.cache.files` SPOT does not apply.
|
|
files = sorted(base.rglob("playwright-junit.xml"))
|
|
if not files:
|
|
sys.stdout.write(
|
|
f"### 🎭 Playwright — {context or 'no reports'}\n\n"
|
|
f"_No `playwright-junit.xml` found under `{base}`._\n"
|
|
)
|
|
return 0
|
|
|
|
summaries = [s for s in (_summarize_file(p) for p in files) if s]
|
|
if not summaries:
|
|
sys.stdout.write(
|
|
f"### 🎭 Playwright — {context or 'unparseable'}\n\n"
|
|
f"_All `playwright-junit.xml` files failed to parse._\n"
|
|
)
|
|
return 0
|
|
|
|
sys.stdout.write(_render(summaries, context) + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|