Files
computer-playbook/tests/unit/python/utils/github/test_playwright_summary.py
T
kevinveenbirkenbachandClaude Opus 5.5 47565781ea style(python): import datetime as dt for ruff 0.17
ruff 0.17.0 adds `datetime` -> `dt` to the default aliases of
flake8-import-conventions, and the project selects ICN, so lint-python
failed with ICN001 in seven files.

The files now import `datetime as dt`. ruff's unsafe fix also renamed
two local names in utils/github/playwright_summary.py into the alias:
the `_to_iso_z` parameter and the parsed value in
`_parse_iso_timestamp`. Both are now called `moment`; with the bare fix,
nine tests of test_playwright_summary.py failed with UnboundLocalError
and AttributeError.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 01:24:31 +02:00

492 lines
18 KiB
Python

"""Unit tests for :mod:`utils.github.playwright_summary`.
Synthetic JUnit XML fixtures are produced under a tempdir so the real
project tree is never touched. Coverage spans the small pure helpers
(``_first_line``, ``_md_escape_cell``), the parser (``_extract_records``,
``_summarize_file``), the renderer (``_render``), and the CLI entry
point (``main``), including the three early-exit branches for missing
directories / no junit files / unparseable junit files.
"""
from __future__ import annotations
import datetime as dt
import io
import os
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from utils.github import playwright_summary as ps
_TWO_SUITES_FIXTURE = """<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="0" failures="0" skipped="0" time="0">
<testsuite name="suite-a" tests="2" failures="1" skipped="0" time="3.5" timestamp="2026-05-17T12:00:00Z">
<testcase name="passes" time="1.0"/>
<testcase name="fails on assertion" time="2.5">
<failure message="expected 1 got 0">stack-line-1\nstack-line-2</failure>
</testcase>
</testsuite>
<testsuite name="suite-b" tests="2" failures="0" skipped="1" time="0.5" timestamp="2026-05-17T12:05:00Z">
<testcase name="another pass" time="0.5"/>
<testcase name="skipped one" time="0">
<skipped message="persona blocked by env"/>
</testcase>
</testsuite>
</testsuites>"""
_ERROR_INSTEAD_OF_FAILURE_FIXTURE = """<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="suite-c" time="1.0">
<testcase name="crashes" time="1.0">
<error message="ENOENT">no such file</error>
</testcase>
</testsuite>
</testsuites>"""
def _write(path: Path, content: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
class TestToIsoZ(unittest.TestCase):
def test_utc_datetime_formats_with_z_suffix(self) -> None:
self.assertEqual(
ps._to_iso_z(dt.datetime(2026, 5, 17, 12, 34, 56, tzinfo=dt.UTC)),
"2026-05-17T12:34:56Z",
)
class TestParseIsoTimestamp(unittest.TestCase):
def test_empty_or_missing_returns_none(self) -> None:
self.assertIsNone(ps._parse_iso_timestamp(""))
self.assertIsNone(ps._parse_iso_timestamp(None))
def test_unparseable_returns_none(self) -> None:
self.assertIsNone(ps._parse_iso_timestamp("bogus"))
def test_iso_with_z_suffix(self) -> None:
result = ps._parse_iso_timestamp("2026-05-17T12:00:00Z")
self.assertEqual(ps._to_iso_z(result), "2026-05-17T12:00:00Z")
def test_naive_iso_treated_as_utc(self) -> None:
result = ps._parse_iso_timestamp("2026-05-17T12:00:00")
self.assertEqual(ps._to_iso_z(result), "2026-05-17T12:00:00Z")
class TestFormatDuration(unittest.TestCase):
def test_sub_second_keeps_decimal(self) -> None:
self.assertEqual(ps._format_duration(0.0), "0.0s")
self.assertEqual(ps._format_duration(0.3), "0.3s")
self.assertEqual(ps._format_duration(0.04), "0.0s")
def test_sub_minute_keeps_decimal(self) -> None:
self.assertEqual(ps._format_duration(12.5), "12.5s")
self.assertEqual(ps._format_duration(59.9), "59.9s")
def test_minute_boundary_rounds_seconds(self) -> None:
self.assertEqual(ps._format_duration(60), "1m 0s")
self.assertEqual(ps._format_duration(83.4), "1m 23s")
self.assertEqual(ps._format_duration(3600), "60m 0s")
class TestFirstLine(unittest.TestCase):
def test_none_returns_empty(self) -> None:
self.assertEqual(ps._first_line(None), "")
def test_whitespace_only_returns_empty(self) -> None:
self.assertEqual(ps._first_line(" \n\t "), "")
def test_extracts_first_logical_line(self) -> None:
self.assertEqual(
ps._first_line("expected 1 got 0\nat /spec/foo.js:42"),
"expected 1 got 0",
)
self.assertEqual(ps._first_line(" padded line "), "padded line")
class TestMdEscapeCell(unittest.TestCase):
def test_pipe_is_escaped(self) -> None:
self.assertEqual(ps._md_escape_cell("a|b|c"), "a\\|b\\|c")
def test_newlines_collapse_to_spaces(self) -> None:
self.assertEqual(ps._md_escape_cell("line1\nline2"), "line1 line2")
class TestExtractRecords(unittest.TestCase):
def test_mixed_pass_fail_skip(self) -> None:
cases = list(
ET.fromstring(_TWO_SUITES_FIXTURE).findall(".//testcase") # noqa: S314 — fixture is hard-coded above
)
records = ps._extract_records(cases)
self.assertEqual(
records,
[
("passes", "passed", "", 1.0),
("fails on assertion", "failed", "expected 1 got 0", 2.5),
("another pass", "passed", "", 0.5),
("skipped one", "skipped", "persona blocked by env", 0.0),
],
)
def test_error_child_counts_as_failed(self) -> None:
cases = list(
ET.fromstring(_ERROR_INSTEAD_OF_FAILURE_FIXTURE).findall(".//testcase") # noqa: S314 — fixture is hard-coded above
)
records = ps._extract_records(cases)
self.assertEqual(records, [("crashes", "failed", "ENOENT", 1.0)])
def test_failure_with_only_text_uses_text(self) -> None:
case = ET.fromstring( # noqa: S314 — fixture literal, not user input
'<testcase name="t"><failure>boom\nstack</failure></testcase>'
)
records = ps._extract_records([case])
self.assertEqual(records, [("t", "failed", "boom", 0.0)])
def test_skipped_without_message_yields_empty_string(self) -> None:
case = ET.fromstring( # noqa: S314 — fixture literal, not user input
'<testcase name="anonymous-skip"><skipped/></testcase>'
)
records = ps._extract_records([case])
self.assertEqual(records, [("anonymous-skip", "skipped", "", 0.0)])
def test_malformed_time_falls_back_to_zero(self) -> None:
case = ET.fromstring( # noqa: S314 — fixture literal, not user input
'<testcase name="bad-time" time="bogus"/>'
)
records = ps._extract_records([case])
self.assertEqual(records, [("bad-time", "passed", "", 0.0)])
class TestPathInfo(unittest.TestCase):
def test_full_variant_pass_layout(self) -> None:
self.assertEqual(
ps._path_info(
Path("/tmp/root/web-app-foo/variant-3/async/playwright-junit.xml")
),
("web-app-foo", "3", "async"),
)
self.assertEqual(
ps._path_info(
Path("/tmp/root/web-app-foo/variant-0/sync/playwright-junit.xml")
),
("web-app-foo", "0", "sync"),
)
def test_variant_only_layout_leaves_pass_empty(self) -> None:
self.assertEqual(
ps._path_info(Path("/tmp/root/web-app-foo/variant-3/playwright-junit.xml")),
("web-app-foo", "3", ""),
)
def test_plain_layout_leaves_variant_and_pass_empty(self) -> None:
self.assertEqual(
ps._path_info(Path("/tmp/root/web-app-foo/playwright-junit.xml")),
("web-app-foo", "", ""),
)
def test_non_variant_subdir_falls_back_to_parent_only(self) -> None:
self.assertEqual(
ps._path_info(Path("/tmp/root/web-app-foo/leftover/playwright-junit.xml")),
("leftover", "", ""),
)
def test_pass_dir_without_variant_parent_is_not_recognized(self) -> None:
self.assertEqual(
ps._path_info(Path("/tmp/root/web-app-foo/sync/playwright-junit.xml")),
("sync", "", ""),
)
class TestSummarizeFile(unittest.TestCase):
def test_plain_layout_fills_only_app(self) -> None:
with TemporaryDirectory() as tmp:
xml_path = _write(
Path(tmp) / "web-app-foo" / "playwright-junit.xml", _TWO_SUITES_FIXTURE
)
summary = ps._summarize_file(xml_path)
assert summary is not None
self.assertEqual(summary["app"], "web-app-foo")
self.assertEqual(summary["variant"], "")
self.assertEqual(summary["pass"], "")
self.assertEqual(len(summary["records"]), 4)
self.assertEqual(summary["records"][1][2], "failed")
self.assertEqual(summary["records"][3][2], "skipped")
self.assertAlmostEqual(summary["records"][0][4], 1.0)
self.assertAlmostEqual(summary["records"][1][4], 2.5)
def test_variant_and_pass_layout_populates_all_three_keys(self) -> None:
with TemporaryDirectory() as tmp:
xml_path = _write(
Path(tmp)
/ "web-app-foo"
/ "variant-2"
/ "async"
/ "playwright-junit.xml",
_TWO_SUITES_FIXTURE,
)
summary = ps._summarize_file(xml_path)
assert summary is not None
self.assertEqual(summary["app"], "web-app-foo")
self.assertEqual(summary["variant"], "2")
self.assertEqual(summary["pass"], "async")
def test_per_case_timestamp_uses_suite_timestamp_plus_offset(self) -> None:
with TemporaryDirectory() as tmp:
xml_path = _write(
Path(tmp) / "web-app-foo" / "playwright-junit.xml",
_TWO_SUITES_FIXTURE,
)
summary = ps._summarize_file(xml_path)
assert summary is not None
records = summary["records"]
self.assertEqual(records[0][0], "2026-05-17T12:00:00Z")
self.assertEqual(records[1][0], "2026-05-17T12:00:01Z")
self.assertEqual(records[2][0], "2026-05-17T12:05:00Z")
self.assertEqual(records[3][0], "2026-05-17T12:05:00Z")
def test_missing_timestamp_falls_back_to_file_mtime(self) -> None:
no_timestamp_fixture = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<testsuites><testsuite name="t" time="1.0">'
'<testcase name="ok" time="1.0"/>'
"</testsuite></testsuites>"
)
with TemporaryDirectory() as tmp:
xml_path = _write(
Path(tmp) / "web-app-foo" / "playwright-junit.xml",
no_timestamp_fixture,
)
target = dt.datetime(2025, 5, 17, 5, 34, 56, tzinfo=dt.UTC).timestamp()
os.utime(xml_path, (target, target))
summary = ps._summarize_file(xml_path)
assert summary is not None
self.assertEqual(summary["records"][0][0], "2025-05-17T05:34:56Z")
def test_unparseable_returns_none(self) -> None:
with TemporaryDirectory() as tmp:
xml_path = _write(Path(tmp) / "x" / "playwright-junit.xml", "<broken")
self.assertIsNone(ps._summarize_file(xml_path))
def test_empty_testsuites_returns_none(self) -> None:
with TemporaryDirectory() as tmp:
xml_path = _write(
Path(tmp) / "x" / "playwright-junit.xml",
"<testsuites></testsuites>",
)
self.assertIsNone(ps._summarize_file(xml_path))
_T0 = "2026-05-17T12:00:00Z"
class TestRender(unittest.TestCase):
def _summary(
self,
app: str,
records: list[tuple[str, str, str, str, float]],
*,
variant: str = "",
pass_: str = "",
) -> dict:
return {"app": app, "variant": variant, "pass": pass_, "records": records}
def test_emits_one_row_per_test_with_status_emoji(self) -> None:
out = ps._render(
[
self._summary(
"web-app-foo",
[
(_T0, "test pass", "passed", "", 0.3),
(_T0, "test fail", "failed", "boom", 12.4),
(_T0, "test skip", "skipped", "blocked", 0.0),
],
variant="variant-1",
pass_="sync",
),
],
"web-app-foo",
)
self.assertIn("### 🎭 Playwright — web-app-foo", out)
self.assertIn(
"| Time | App | Variant | Pass | Test | Status | Duration | Message |",
out,
)
self.assertIn(
f"| `{_T0}` | `web-app-foo` | variant-1 | sync | test pass | 🟢 | 0.3s | |",
out,
)
self.assertIn(
f"| `{_T0}` | `web-app-foo` | variant-1 | sync | test fail | 🔴 | 12.4s | boom |",
out,
)
self.assertIn(
f"| `{_T0}` | `web-app-foo` | variant-1 | sync | test skip | 🔵 | 0.0s | blocked |",
out,
)
def test_status_legend_is_emitted(self) -> None:
out = ps._render([self._summary("foo", [(_T0, "t", "passed", "", 1.0)])], "ctx")
self.assertIn("🟢 passed", out)
self.assertIn("🔴 failed", out)
self.assertIn("🔵 skipped", out)
def test_multiple_apps_grouped_in_single_table(self) -> None:
out = ps._render(
[
self._summary(
"a",
[(_T0, "t1", "passed", "", 0.1)],
variant="variant-0",
pass_="sync",
),
self._summary(
"b",
[(_T0, "t2", "failed", "x", 65.0)],
variant="variant-0",
pass_="async",
),
],
"",
)
self.assertTrue(out.startswith("### 🎭 Playwright\n"))
self.assertIn(f"| `{_T0}` | `a` | variant-0 | sync | t1 | 🟢 | 0.1s | |", out)
self.assertIn(
f"| `{_T0}` | `b` | variant-0 | async | t2 | 🔴 | 1m 5s | x |", out
)
self.assertEqual(
out.count(
"| Time | App | Variant | Pass | Test | Status | Duration | Message |"
),
1,
)
def test_rows_are_sorted_ascending_by_time(self) -> None:
out = ps._render(
[
self._summary(
"late",
[
("2026-05-17T12:00:05Z", "late-1", "passed", "", 0.1),
("2026-05-17T12:00:09Z", "late-2", "passed", "", 0.1),
],
),
self._summary(
"early",
[
("2026-05-17T12:00:01Z", "early-1", "passed", "", 0.1),
("2026-05-17T12:00:07Z", "early-2", "passed", "", 0.1),
],
),
],
"ctx",
)
body = out.split("|---|---|---|---|---|:---:|---:|---|\n", 1)[1]
order = [line.split("|")[5].strip() for line in body.splitlines() if line]
self.assertEqual(order, ["early-1", "late-1", "early-2", "late-2"])
def test_plain_layout_leaves_variant_and_pass_cells_empty(self) -> None:
out = ps._render(
[self._summary("foo", [(_T0, "t1", "passed", "", 1.0)])],
"ctx",
)
self.assertIn(f"| `{_T0}` | `foo` | | | t1 | 🟢 | 1.0s | |", out)
def test_message_is_truncated(self) -> None:
long_msg = "x" * (ps._MAX_MSG_LEN + 50)
out = ps._render(
[self._summary("foo", [(_T0, "name", "failed", long_msg, 1.0)])],
"ctx",
)
truncated = "x" * ps._MAX_MSG_LEN + "…"
self.assertIn(truncated, out)
def test_pipes_in_name_and_message_are_escaped(self) -> None:
out = ps._render(
[
self._summary(
"foo",
[(_T0, 'up{job="x|y"} == 1', "failed", "got 0|1", 1.0)],
)
],
"ctx",
)
self.assertIn('up{job="x\\|y"} == 1', out)
self.assertIn("got 0\\|1", out)
class TestMain(unittest.TestCase):
def _run(self, *argv: str) -> tuple[int, str, str]:
stdout = io.StringIO()
stderr = io.StringIO()
with patch.object(ps, "sys") as fake_sys:
fake_sys.argv = ["playwright_summary.py", *argv]
fake_sys.stdout = stdout
fake_sys.stderr = stderr
rc = ps.main()
return rc, stdout.getvalue(), stderr.getvalue()
def test_missing_argv_returns_two(self) -> None:
rc, out, err = self._run()
self.assertEqual(rc, 2)
self.assertIn("Usage", err)
self.assertEqual(out, "")
def test_nonexistent_dir_emits_friendly_note(self) -> None:
with TemporaryDirectory() as tmp:
missing = Path(tmp) / "absent"
rc, out, _err = self._run(str(missing), "web-app-foo")
self.assertEqual(rc, 0)
self.assertIn("does not exist", out)
self.assertIn("web-app-foo", out)
def test_empty_dir_emits_no_junit_note(self) -> None:
with TemporaryDirectory() as tmp:
rc, out, _err = self._run(tmp, "web-app-foo")
self.assertEqual(rc, 0)
self.assertIn("No `playwright-junit.xml` found", out)
def test_all_unparseable_emits_failure_note(self) -> None:
with TemporaryDirectory() as tmp:
_write(Path(tmp) / "x" / "playwright-junit.xml", "<broken")
rc, out, _err = self._run(tmp, "web-app-foo")
self.assertEqual(rc, 0)
self.assertIn("failed to parse", out)
def test_happy_path_renders_table(self) -> None:
with TemporaryDirectory() as tmp:
_write(
Path(tmp)
/ "web-app-foo"
/ "variant-1"
/ "sync"
/ "playwright-junit.xml",
_TWO_SUITES_FIXTURE,
)
rc, out, _err = self._run(tmp, "web-app-foo")
self.assertEqual(rc, 0)
self.assertIn("### 🎭 Playwright — web-app-foo", out)
self.assertIn(
"| `2026-05-17T12:00:00Z` | `web-app-foo` | 1 | sync | passes | "
"🟢 | 1.0s | |",
out,
)
self.assertIn(
"| `2026-05-17T12:00:01Z` | `web-app-foo` | 1 | sync | "
"fails on assertion | 🔴 | 2.5s | expected 1 got 0 |",
out,
)
self.assertIn(
"| `2026-05-17T12:05:00Z` | `web-app-foo` | 1 | sync | "
"skipped one | 🔵 | 0.0s | persona blocked by env |",
out,
)
if __name__ == "__main__": # pragma: no cover
unittest.main()