Skip to content

Commit b751597

Browse files
authored
feat(ci-insights): Accept single-suite JUnit XML files (#1287)
Parse XML documents whose root is `<testsuite>` (no `<testsuites>` wrapper), in addition to the multi-suite form. Nested `<testsuite>` descendants under a `<testsuite>` root are traversed too, matching the `<testsuites>` path. Many test frameworks emit a single `<testsuite>` as the document root, and those files were previously rejected with "no testsuites tag found". Fixes: MRGFY-7026
1 parent 3475a91 commit b751597

4 files changed

Lines changed: 263 additions & 6 deletions

File tree

mergify_cli/ci/junit_processing/junit.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,15 @@ async def junit_to_spans(
6969
# strict on the format, as there is no official standard and at least 3 versions
7070
# in Junit itself, most implementations never implement 100% of the original format.
7171

72-
if root.tag != "testsuites":
73-
msg = "no testsuites tag found"
74-
raise InvalidJunitXMLError(msg)
72+
if root.tag == "testsuites":
73+
testsuites = root.findall(".//{*}testsuite")
74+
elif root.tag == "testsuite":
75+
testsuites = [root, *root.findall(".//{*}testsuite")]
76+
else:
77+
testsuites = []
7578

76-
testsuites = root.findall(".//{*}testsuite")
7779
if not testsuites:
78-
msg = "no testsuite tag found"
79-
raise InvalidJunitXMLError(msg)
80+
raise InvalidJunitXMLError("no testsuites or testsuite tag found")
8081

8182
now = time.time_ns()
8283

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<testsuite name="Outer" time="3.0">
3+
<testcase name="directCase" classname="Outer" time="1.0" />
4+
<testsuite name="Inner" time="2.0">
5+
<testcase name="nestedCase" classname="Inner" time="2.0" />
6+
</testsuite>
7+
</testsuite>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<testsuite name="Tests.Registration" time="6.605871">
3+
<testcase name="testCase1" classname="Tests.Registration" time="2.113871" />
4+
<testcase name="testCase2" classname="Tests.Registration" time="1.051">
5+
<skipped message="Test was skipped." />
6+
</testcase>
7+
<testcase name="testCase3" classname="Tests.Registration" time="3.441">
8+
<error message="invalid literal for int() with base 10: 'foobar'" type="ValueError">
9+
bip, bip, bip, error!
10+
</error>
11+
</testcase>
12+
</testsuite>

mergify_cli/tests/ci/test_junit.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,243 @@ async def test_traceparent_injection(
555555
assert span.context.trace_id == 0x80E1AFED08E019FC1110464CFA66635C
556556

557557

558+
@mock.patch.object(detector, "get_ci_provider", return_value="github_actions")
559+
@mock.patch.object(detector, "get_pipeline_name", return_value="PIPELINE")
560+
@mock.patch.object(detector, "get_job_name", return_value="JOB")
561+
@mock.patch.object(
562+
detector,
563+
"get_cicd_pipeline_runner_name",
564+
return_value="self-hosted",
565+
)
566+
@mock.patch.object(detector, "get_cicd_pipeline_run_id", return_value=123)
567+
@mock.patch.object(detector, "get_cicd_pipeline_run_attempt", return_value=1)
568+
@mock.patch.object(
569+
detector,
570+
"get_head_sha",
571+
return_value="3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
572+
)
573+
@mock.patch.object(
574+
detector,
575+
"get_head_ref_name",
576+
return_value="refs/heads/main",
577+
)
578+
async def test_parse_single_suite(
579+
_get_ci_provider: mock.Mock,
580+
_get_pipeline_name: mock.Mock,
581+
_get_job_name: mock.Mock,
582+
_get_cicd_pipeline_runner_name: mock.Mock,
583+
_get_cicd_pipeline_run_id: mock.Mock,
584+
_get_cicd_pipeline_run_attempt: mock.Mock,
585+
_get_head_sha: mock.Mock,
586+
_get_head_ref_name: mock.Mock,
587+
monkeypatch: pytest.MonkeyPatch,
588+
) -> None:
589+
monkeypatch.setenv("MERGIFY_TEST_JOB_NAME", "foobar")
590+
filename = (
591+
pathlib.Path(__file__).parent / "fixtures" / "junit_example_single_suite.xml"
592+
)
593+
run_id = (32312).to_bytes(8, "big").hex()
594+
spans = await junit.junit_to_spans(
595+
run_id,
596+
filename.read_bytes(),
597+
"python",
598+
"unittest",
599+
)
600+
assert spans[0].parent is None
601+
602+
dictified_spans = [json.loads(span.to_json()) for span in spans]
603+
trace_id = "0x" + opentelemetry.trace.span.format_trace_id(
604+
spans[1].context.trace_id,
605+
)
606+
resource_attributes = {
607+
"test.run.id": run_id,
608+
"cicd.pipeline.name": "PIPELINE",
609+
"cicd.pipeline.task.name": "JOB",
610+
"cicd.pipeline.run.id": 123,
611+
"cicd.pipeline.run.attempt": 1,
612+
"cicd.pipeline.runner.name": "self-hosted",
613+
"cicd.provider.name": "github_actions",
614+
"vcs.ref.head.revision": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
615+
"vcs.ref.head.name": "refs/heads/main",
616+
"service.name": "unknown_service",
617+
"telemetry.sdk.language": "python",
618+
"telemetry.sdk.name": "opentelemetry",
619+
"telemetry.sdk.version": anys.ANY_STR,
620+
"mergify.test.job.name": "foobar",
621+
}
622+
assert dictified_spans == [
623+
{
624+
"attributes": {
625+
"test.framework": "unittest",
626+
"test.language": "python",
627+
"test.scope": "session",
628+
},
629+
"context": {
630+
"span_id": anys.ANY_STR,
631+
"trace_id": trace_id,
632+
"trace_state": "[]",
633+
},
634+
"end_time": anys.ANY_DATETIME_STR,
635+
"events": [],
636+
"kind": "SpanKind.INTERNAL",
637+
"links": [],
638+
"name": "test session",
639+
"parent_id": None,
640+
"resource": {
641+
"attributes": resource_attributes,
642+
"schema_url": "",
643+
},
644+
"start_time": anys.ANY_DATETIME_STR,
645+
"status": {
646+
"status_code": "UNSET",
647+
},
648+
},
649+
{
650+
"attributes": {
651+
"test.case.name": "Tests.Registration",
652+
"test.scope": "suite",
653+
"test.framework": "unittest",
654+
"test.language": "python",
655+
},
656+
"context": {
657+
"span_id": anys.ANY_STR,
658+
"trace_id": trace_id,
659+
"trace_state": "[]",
660+
},
661+
"end_time": anys.ANY_DATETIME_STR,
662+
"events": [],
663+
"kind": "SpanKind.INTERNAL",
664+
"links": [],
665+
"name": "Tests.Registration",
666+
"parent_id": anys.ANY_STR,
667+
"resource": {
668+
"attributes": resource_attributes,
669+
"schema_url": "",
670+
},
671+
"start_time": anys.ANY_DATETIME_STR,
672+
"status": {
673+
"status_code": "UNSET",
674+
},
675+
},
676+
{
677+
"attributes": {
678+
"test.case.name": "Tests.Registration.testCase1",
679+
"code.function.name": "Tests.Registration.testCase1",
680+
"test.case.result.status": "passed",
681+
"test.scope": "case",
682+
"test.framework": "unittest",
683+
"test.language": "python",
684+
"cicd.test.quarantined": False,
685+
},
686+
"context": {
687+
"span_id": anys.ANY_STR,
688+
"trace_id": trace_id,
689+
"trace_state": "[]",
690+
},
691+
"end_time": anys.ANY_DATETIME_STR,
692+
"events": [],
693+
"kind": "SpanKind.INTERNAL",
694+
"links": [],
695+
"name": "Tests.Registration.testCase1",
696+
"parent_id": anys.ANY_STR,
697+
"resource": {
698+
"attributes": resource_attributes,
699+
"schema_url": "",
700+
},
701+
"start_time": anys.ANY_DATETIME_STR,
702+
"status": {
703+
"status_code": "OK",
704+
},
705+
},
706+
{
707+
"attributes": {
708+
"test.case.name": "Tests.Registration.testCase2",
709+
"code.function.name": "Tests.Registration.testCase2",
710+
"test.case.result.status": "skipped",
711+
"test.scope": "case",
712+
"test.framework": "unittest",
713+
"test.language": "python",
714+
"cicd.test.quarantined": False,
715+
},
716+
"context": {
717+
"span_id": anys.ANY_STR,
718+
"trace_id": trace_id,
719+
"trace_state": "[]",
720+
},
721+
"end_time": anys.ANY_DATETIME_STR,
722+
"events": [],
723+
"kind": "SpanKind.INTERNAL",
724+
"links": [],
725+
"name": "Tests.Registration.testCase2",
726+
"parent_id": anys.ANY_STR,
727+
"resource": {
728+
"attributes": resource_attributes,
729+
"schema_url": "",
730+
},
731+
"start_time": anys.ANY_DATETIME_STR,
732+
"status": {
733+
"status_code": "OK",
734+
},
735+
},
736+
{
737+
"attributes": {
738+
"exception.message": "invalid literal for int() with base 10: 'foobar'",
739+
"exception.stacktrace": "bip, bip, bip, error!",
740+
"exception.type": "ValueError",
741+
"test.case.name": "Tests.Registration.testCase3",
742+
"code.function.name": "Tests.Registration.testCase3",
743+
"test.case.result.status": "failed",
744+
"test.scope": "case",
745+
"test.framework": "unittest",
746+
"test.language": "python",
747+
"cicd.test.quarantined": False,
748+
},
749+
"context": {
750+
"span_id": anys.ANY_STR,
751+
"trace_id": trace_id,
752+
"trace_state": "[]",
753+
},
754+
"end_time": anys.ANY_DATETIME_STR,
755+
"events": [],
756+
"kind": "SpanKind.INTERNAL",
757+
"links": [],
758+
"name": "Tests.Registration.testCase3",
759+
"parent_id": anys.ANY_STR,
760+
"resource": {
761+
"attributes": resource_attributes,
762+
"schema_url": "",
763+
},
764+
"start_time": anys.ANY_DATETIME_STR,
765+
"status": {
766+
"status_code": "ERROR",
767+
},
768+
},
769+
]
770+
771+
772+
async def test_parse_single_suite_with_nested_suites() -> None:
773+
filename = (
774+
pathlib.Path(__file__).parent
775+
/ "fixtures"
776+
/ "junit_example_nested_single_suite.xml"
777+
)
778+
run_id = (32312).to_bytes(8, "big").hex()
779+
spans = await junit.junit_to_spans(run_id, filename.read_bytes())
780+
781+
suite_names = [
782+
span.attributes["test.case.name"]
783+
for span in spans
784+
if span.attributes is not None and span.attributes.get("test.scope") == "suite"
785+
]
786+
case_names = [
787+
span.attributes["test.case.name"]
788+
for span in spans
789+
if span.attributes is not None and span.attributes.get("test.scope") == "case"
790+
]
791+
assert suite_names == ["Outer", "Inner"]
792+
assert case_names == ["Outer.directCase", "Inner.nestedCase"]
793+
794+
558795
# ── Exit code pinning tests ──
559796

560797

0 commit comments

Comments
 (0)