Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .workflows-lib
Submodule .workflows-lib updated from 477e9d to 48f12e
1 change: 1 addition & 0 deletions agents/codex-405.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!-- bootstrap for codex on issue #405 -->
2 changes: 1 addition & 1 deletion autofix_report_enriched.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"changed": true, "classification": {"total": 0, "new": 0, "allowed": 0}, "timestamp": "2025-12-31T17:57:55Z", "files": ["tests/scripts/test_keepalive_metrics_dashboard.py"]}
{"changed": true, "classification": {"total": 0, "new": 0, "allowed": 0}, "timestamp": "2025-12-31T19:30:16Z", "files": ["tests/scripts/test_ledger_validate.py"]}
2 changes: 1 addition & 1 deletion scripts/ci_cosmetic_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ def stage_and_commit(
summary: str,
branch_suffix: str | None,
) -> str:
branch_suffix = branch_suffix or datetime.utcnow().strftime("%Y%m%d%H%M%S")
branch_suffix = branch_suffix or datetime.now(UTC).strftime("%Y%m%d%H%M%S")
branch = f"{BRANCH_PREFIX}-{branch_suffix}"
_run(["git", "checkout", "-B", branch], cwd=root)
_run(["git", "add", *{str(p.relative_to(root)) for p in paths}], cwd=root)
Expand Down
4 changes: 3 additions & 1 deletion scripts/classify_test_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ def classify_reports(paths: Iterable[str | Path]) -> dict[str, object]:
root = tree.getroot()
testcases = list(root.iter("testcase"))
for testcase in testcases:
failure_node = testcase.find("failure") or testcase.find("error")
failure_node = testcase.find("failure")
if failure_node is None:
failure_node = testcase.find("error")
if failure_node is None:
continue
marker_set = _extract_markers(testcase)
Expand Down
177 changes: 177 additions & 0 deletions tests/scripts/test_classify_test_failures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import json
import xml.etree.ElementTree as ET
from pathlib import Path

from scripts import classify_test_failures


def _write_junit(tmp_path: Path, name: str, body: str) -> Path:
path = tmp_path / name
path.write_text(
'<?xml version="1.0" encoding="utf-8"?>\n<testsuite>\n' f"{body}\n</testsuite>\n",
encoding="utf-8",
)
return path


def test_classify_reports_ignores_missing_file() -> None:
summary = classify_test_failures.classify_reports(["missing-report.xml"])

assert summary["total_failures"] == 0
assert summary["has_failures"] is False
assert summary["only_cosmetic"] is False
assert summary["cosmetic"] == []
assert summary["runtime"] == []
assert summary["unknown"] == []


def test_classify_reports_handles_parse_error(tmp_path: Path) -> None:
report = tmp_path / "bad-report.xml"
report.write_text("<testsuite>", encoding="utf-8")

summary = classify_test_failures.classify_reports([report])

assert summary["total_failures"] == 1
assert summary["has_failures"] is True
assert summary["unknown"][0]["failure_type"] == "error"
assert "Unable to parse JUnit XML" in summary["unknown"][0]["message"]
assert summary["unknown"][0]["id"].startswith("<parse-error>:")


def test_classify_reports_classifies_markers_and_dedupes(tmp_path: Path) -> None:
body = """
<testcase classname="pkg.test_mod" name="test_runtime">
<properties>
<property name="markers" value="runtime, smoke"/>
</properties>
<failure message="boom">trace<detail/></failure>
</testcase>
<testcase classname="pkg.test_mod" name="test_runtime">
<properties>
<property name="Marker:Runtime" value="1"/>
</properties>
<failure message="boom">trace<detail/></failure>
</testcase>
<testcase classname="pkg.test_mod" name="test_cosmetic">
<properties>
<property name="marker:cosmetic" value="1"/>
</properties>
<error>stack</error>
</testcase>
<testcase classname="pkg.test_mod" name="test_unknown_marker">
<properties>
<property name="markers" value="flaky"/>
</properties>
<failure message="nope"><detail/></failure>
</testcase>
<testcase classname="pkg.test_mod" name="test_default_runtime">
<failure message="default"><detail/></failure>
</testcase>
"""
report = _write_junit(tmp_path, "report.xml", body)

summary = classify_test_failures.classify_reports([report])

assert summary["total_failures"] == 4
assert summary["has_failures"] is True
assert len(summary["runtime"]) == 2
assert len(summary["cosmetic"]) == 1
assert len(summary["unknown"]) == 1
assert summary["cosmetic"][0]["failure_type"] == "error"
assert summary["runtime"][0]["message"].startswith("boom:")


def test_failure_message_and_test_id_helpers() -> None:
failure_case = ET.fromstring(
'<testcase classname="pkg.mod" name="test_it">'
'<failure message="boom">trace</failure>'
"</testcase>"
)
message, failure_type = classify_test_failures._failure_message(failure_case)

assert failure_type == "failure"
assert message == "boom: trace"
assert classify_test_failures._test_id(failure_case, Path("report.xml")) == "pkg.mod::test_it"

error_case = ET.fromstring('<testcase name="test_err"><error>stack</error></testcase>')
message, failure_type = classify_test_failures._failure_message(error_case)

assert failure_type == "error"
assert message == "stack"
assert classify_test_failures._test_id(error_case, Path("report.xml")) == "test_err"

unnamed_case = ET.fromstring("<testcase></testcase>")
assert (
classify_test_failures._test_id(unnamed_case, Path("report.xml")) == "report.xml::testcase"
)
empty_case = ET.fromstring("<testcase></testcase>")
assert classify_test_failures._failure_message(empty_case) == ("", "failure")


def test_extract_markers_skips_blank_values() -> None:
testcase = ET.fromstring(
"<testcase>"
"<properties>"
'<property name="markers" value=""/>'
'<property name="marker:runtime" value="1"/>'
'<property name="note" value="ignored"/>'
"</properties>"
"</testcase>"
)

markers = classify_test_failures._extract_markers(testcase)

assert markers == {"runtime"}


def test_classify_reports_skips_non_failures(tmp_path: Path) -> None:
report = _write_junit(
tmp_path,
"report.xml",
"""
<testcase classname="pkg.test_mod" name="test_ok"/>
<testcase classname="pkg.test_mod" name="test_error">
<error>stack</error>
</testcase>
""",
)

summary = classify_test_failures.classify_reports([report])

assert summary["total_failures"] == 1


def test_main_writes_output_file(tmp_path: Path, capsys, monkeypatch) -> None:
report = _write_junit(
tmp_path,
"report.xml",
"""
<testcase classname="pkg.test_mod" name="test_runtime">
<failure message="boom"><detail/></failure>
</testcase>
""",
)
output = tmp_path / "summary.json"

monkeypatch.chdir(tmp_path)
status = classify_test_failures.main([report.name, "--output", str(output)])

assert status == 0
assert output.exists()
payload = json.loads(output.read_text(encoding="utf-8"))
assert payload["has_failures"] is True
assert payload["total_failures"] == 1
assert "cosmetic" in payload

captured = capsys.readouterr()
assert '"total_failures": 1' in captured.out


def test_main_with_missing_report_prints_empty_summary(tmp_path: Path, capsys, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)

status = classify_test_failures.main(["missing-report.xml"])

assert status == 0
captured = capsys.readouterr()
assert '"total_failures": 0' in captured.out
64 changes: 64 additions & 0 deletions tests/scripts/test_coverage_history_append.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def test_load_existing_skips_invalid_lines(tmp_path: Path) -> None:
assert records == [{"run_id": 1}, {"run_id": 2}]


def test_load_existing_returns_empty_when_missing(tmp_path: Path) -> None:
history_path = tmp_path / "missing.ndjson"

records = coverage_history_append.load_existing(history_path)

assert records == []


def test_main_replaces_matching_run_id_and_sorts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down Expand Up @@ -55,6 +63,62 @@ def test_main_replaces_matching_run_id_and_sorts(
assert records[1]["coverage"] == 75.0


def test_main_sorts_by_run_id_when_no_run_number(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
history_path = tmp_path / "history.ndjson"
record_path = tmp_path / "record.json"

_write_ndjson(
history_path,
[
{"run_id": 3, "coverage": 50.0},
{"run_id": 1, "coverage": 45.0},
],
)
record_path.write_text(
json.dumps({"run_id": 2, "coverage": 60.0}),
encoding="utf-8",
)

monkeypatch.setenv("HISTORY_PATH", str(history_path))
monkeypatch.setenv("RECORD_PATH", str(record_path))

exit_code = coverage_history_append.main()

assert exit_code == 0
records = _read_ndjson(history_path)
assert [record["run_id"] for record in records] == [1, 2, 3]


def test_main_appends_record_without_run_id(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
history_path = tmp_path / "history.ndjson"
record_path = tmp_path / "record.json"

_write_ndjson(
history_path,
[
{"run_number": 1, "coverage": 50.0},
{"run_number": 2, "coverage": 55.0},
],
)
record_path.write_text(
json.dumps({"run_number": 3, "coverage": 60.0}),
encoding="utf-8",
)

monkeypatch.setenv("HISTORY_PATH", str(history_path))
monkeypatch.setenv("RECORD_PATH", str(record_path))

exit_code = coverage_history_append.main()

assert exit_code == 0
records = _read_ndjson(history_path)
assert len(records) == 3


def test_main_skips_missing_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
history_path = tmp_path / "history.ndjson"
record_path = tmp_path / "missing.json"
Expand Down
10 changes: 10 additions & 0 deletions tests/scripts/test_fix_cosmetic_aggregate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from pathlib import Path

from scripts import fix_cosmetic_aggregate


def test_main_returns_when_target_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(fix_cosmetic_aggregate, "ROOT", Path(tmp_path))
monkeypatch.setattr(fix_cosmetic_aggregate, "TARGET", Path("missing.py"))

assert fix_cosmetic_aggregate.main() == 0
Loading
Loading