180 lines
6.5 KiB
Python
Executable File
180 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Audit Playwright blob reports for retries, errors, and unexpected results."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
import sys
|
|
import zipfile
|
|
from collections import Counter
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Iterable
|
|
|
|
|
|
EXPECTED_STATUSES = {"passed", "skipped"}
|
|
|
|
|
|
def is_report_jsonl(path: str | Path) -> bool:
|
|
name = Path(path).name
|
|
return name == "report.jsonl" or (name.startswith("report-") and name.endswith(".jsonl"))
|
|
|
|
|
|
def is_report_zip(path: str | Path) -> bool:
|
|
name = Path(path).name
|
|
return name.startswith("report-") and name.endswith(".zip")
|
|
|
|
|
|
def discover_reports(inputs: Iterable[Path]) -> list[tuple[str, Path, str | None]]:
|
|
"""Return sorted (kind, path, member) tuples without duplicate inputs."""
|
|
|
|
reports: dict[tuple[str, str, str | None], tuple[str, Path, str | None]] = {}
|
|
|
|
for input_path in inputs:
|
|
if not input_path.exists():
|
|
raise FileNotFoundError(input_path)
|
|
|
|
candidates = [input_path] if input_path.is_file() else sorted(input_path.rglob("*"))
|
|
for candidate in candidates:
|
|
if not candidate.is_file():
|
|
continue
|
|
if candidate.suffix == ".zip" and (is_report_zip(candidate) or candidate == input_path):
|
|
try:
|
|
with zipfile.ZipFile(candidate) as archive:
|
|
for member in archive.namelist():
|
|
if is_report_jsonl(PurePosixPath(member).name):
|
|
key = ("zip", str(candidate), member)
|
|
reports[key] = ("zip", candidate, member)
|
|
except zipfile.BadZipFile as exc:
|
|
raise ValueError(f"invalid Playwright report archive: {candidate}") from exc
|
|
elif is_report_jsonl(candidate):
|
|
key = ("file", str(candidate), None)
|
|
reports[key] = ("file", candidate, None)
|
|
|
|
return sorted(reports.values(), key=lambda report: (str(report[1]), report[2] or ""))
|
|
|
|
|
|
def iter_report_lines(kind: str, path: Path, member: str | None) -> Iterable[tuple[int, str]]:
|
|
if kind == "file":
|
|
with path.open("r", encoding="utf-8") as stream:
|
|
for line_number, line in enumerate(stream, start=1):
|
|
yield line_number, line
|
|
return
|
|
|
|
assert member is not None
|
|
with zipfile.ZipFile(path) as archive:
|
|
with archive.open(member) as binary_stream:
|
|
stream = io.TextIOWrapper(binary_stream, encoding="utf-8")
|
|
for line_number, line in enumerate(stream, start=1):
|
|
yield line_number, line
|
|
|
|
|
|
def result_from(event: dict) -> dict:
|
|
params = event.get("params")
|
|
if not isinstance(params, dict):
|
|
params = {}
|
|
result = params.get("result")
|
|
if not isinstance(result, dict):
|
|
result = event.get("result")
|
|
return result if isinstance(result, dict) else {}
|
|
|
|
|
|
def audit_reports(reports: list[tuple[str, Path, str | None]]) -> dict:
|
|
attempt_count = 0
|
|
retry_attempt_count = 0
|
|
status_counts: Counter[str] = Counter()
|
|
unexpected_statuses: Counter[str] = Counter()
|
|
error_result_count = 0
|
|
parse_error_count = 0
|
|
event_count = 0
|
|
report_names: list[str] = []
|
|
|
|
for kind, path, member in reports:
|
|
report_name = f"{path}!{member}" if member is not None else str(path)
|
|
report_names.append(report_name)
|
|
for line_number, raw_line in iter_report_lines(kind, path, member):
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
parse_error_count += 1
|
|
continue
|
|
if not isinstance(event, dict):
|
|
continue
|
|
|
|
method = event.get("method") or event.get("event") or event.get("type")
|
|
if method not in {"onTestBegin", "onTestEnd"}:
|
|
continue
|
|
event_count += 1
|
|
result = result_from(event)
|
|
|
|
if method == "onTestBegin":
|
|
attempt_count += 1
|
|
retry = result.get("retry", 0)
|
|
if isinstance(retry, (int, float)) and not isinstance(retry, bool) and retry > 0:
|
|
retry_attempt_count += 1
|
|
continue
|
|
|
|
status = result.get("status")
|
|
if status is None:
|
|
params = event.get("params")
|
|
status = params.get("status") if isinstance(params, dict) else event.get("status")
|
|
status_name = str(status) if status is not None else "unknown"
|
|
status_counts[status_name] += 1
|
|
if status_name not in EXPECTED_STATUSES:
|
|
unexpected_statuses[status_name] += 1
|
|
errors = result.get("errors")
|
|
if errors:
|
|
error_result_count += 1
|
|
|
|
violations: list[str] = []
|
|
if retry_attempt_count:
|
|
violations.append(f"retry_attempts={retry_attempt_count}")
|
|
if error_result_count:
|
|
violations.append(f"error_results={error_result_count}")
|
|
if unexpected_statuses:
|
|
violations.append(f"unexpected_statuses={dict(sorted(unexpected_statuses.items()))}")
|
|
if parse_error_count:
|
|
violations.append(f"parse_errors={parse_error_count}")
|
|
if not event_count:
|
|
violations.append("no_test_events")
|
|
|
|
return {
|
|
"reports": report_names,
|
|
"report_count": len(report_names),
|
|
"attempt_count": attempt_count,
|
|
"retry_attempt_count": retry_attempt_count,
|
|
"status_counts": dict(sorted(status_counts.items())),
|
|
"error_result_count": error_result_count,
|
|
"unexpected_statuses": dict(sorted(unexpected_statuses.items())),
|
|
"parse_error_count": parse_error_count,
|
|
"violations": violations,
|
|
}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Audit Playwright report-*.zip/report.jsonl artifacts for retries and errors."
|
|
)
|
|
parser.add_argument("paths", nargs="+", type=Path, help="report files, archives, or directories")
|
|
args = parser.parse_args(argv)
|
|
|
|
try:
|
|
reports = discover_reports(args.paths)
|
|
if not reports:
|
|
raise ValueError("no report-*.zip or report.jsonl artifacts found")
|
|
summary = audit_reports(reports)
|
|
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
print(f"playwright-blob-audit: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
print(json.dumps(summary, indent=2, sort_keys=True))
|
|
return 1 if summary["violations"] else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|