Files
computer-playbook/utils/github/playwright_summary.py
T
kevinveenbirkenbachandClaude Opus 4.7 0aa34b903e feat(playwright): persist artifacts per variant+pass, add Time column
- Matrix-deploy orchestrator now pushes `VARIANT_INDEX` (0-based round
  id) alongside the existing `ASYNC_ENABLED` extra var for both PASS 1
  (sync) and PASS 2 (async) runs. Test-e2e-playwright's archive step
  consumes both to derive a per-(variant, pass) destination so a
  subsequent round cannot overwrite the previous one's reports.
- New `roles/test-e2e-playwright/files/archive_playwright_reports.sh`
  moves the playwright-junit.xml / playwright-report/ / test-results/
  outputs into `variant-<N>/<sync|async>/` under the role's reports
  dir, idempotently (re-running the same tuple replaces rather than
  fails). `run_one.yml` invokes the script via `ansible.builtin.script`
  so the YAML stays a single 7-line task.
- `VARIANT_INDEX: 0` lives in `defaults/main.yml` to keep the variable
  defined for dev / non-matrix invocations. Role defaults are not
  considered SPOT, so the `| default(0)` belt-and-suspenders inside
  the script invocation stays valid against the spot-default lint.
- Summary script now renders an 8-column table: Time | App | Variant |
  Pass | Test | Status | Duration | Message. Per-test ISO timestamps
  are derived from the JUnit `<testsuite timestamp>` attribute plus the
  cumulative duration of preceding cases; file mtime is the fallback
  when the attribute is absent. App / Variant / Pass are extracted
  from the artefact path layout produced by the archive script.
- Matrix-deploy unit tests refreshed to expect the new
  `extra_ansible_vars` shape; summary unit tests gain coverage for
  `_to_iso_z`, `_parse_iso_timestamp`, the four supported path
  layouts, and the file-mtime fallback (55 tests total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:30:06 +02:00

235 lines
7.8 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, parent.name
if parent.name.startswith("variant-"):
return grand.name, parent.name, ""
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 |",
"|---|---|---|---|---|:---:|---:|---|",
]
for s in summaries:
for time_iso, name, status, msg, duration in s["records"]:
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}` | `{s['app']}` | {s['variant']} | {s['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())