Files
computer-playbook/tests/unit/python/utils/github/test_playwright_summary.py
T
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00

496 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
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(
datetime.datetime(2026, 5, 17, 12, 34, 56, tzinfo=datetime.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 = datetime.datetime(
2025, 5, 17, 5, 34, 56, tzinfo=datetime.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()