Skip to content

Commit aa7d220

Browse files
authored
feat: add --test-exit-code flag to detect silent test runner failures (#1053)
When a test runner crashes mid-run (OOM, segfault, etc.), it may produce a partial JUnit XML where all recorded tests passed. The CLI would read this partial report, see all-pass, and exit 0 — masking the real failure. This adds a --test-exit-code / -e flag (env: MERGIFY_TEST_EXIT_CODE) to the junit-process and junit-upload commands. When the flag indicates a non-zero exit code but the JUnit report shows no failures, the CLI warns about the likely incomplete report and exits 1. The flag is optional and fully backwards compatible. When not provided, behavior is unchanged. MRGFY-6252
1 parent fd3ab6f commit aa7d220

3 files changed

Lines changed: 195 additions & 0 deletions

File tree

mergify_cli/ci/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ def _process_tests_target_branch(
101101
envvar=["GITHUB_BASE_REF", "GITHUB_HEAD_REF", "GITHUB_REF_NAME", "GITHUB_REF"],
102102
callback=_process_tests_target_branch,
103103
)
104+
@click.option(
105+
"--test-exit-code",
106+
"-e",
107+
help="Exit code of the test runner process. Used to detect silent failures where the runner crashed but the JUnit report appears clean.",
108+
type=int,
109+
required=False,
110+
default=None,
111+
envvar="MERGIFY_TEST_EXIT_CODE",
112+
)
104113
@click.argument(
105114
"files",
106115
nargs=-1,
@@ -116,6 +125,7 @@ async def junit_upload(
116125
test_framework: str | None,
117126
test_language: str | None,
118127
tests_target_branch: str,
128+
test_exit_code: int | None,
119129
files: tuple[str, ...],
120130
) -> None:
121131
await junit_processing_cli.process_junit_files(
@@ -126,6 +136,7 @@ async def junit_upload(
126136
test_language=test_language,
127137
tests_target_branch=tests_target_branch,
128138
files=files,
139+
test_exit_code=test_exit_code,
129140
)
130141

131142

@@ -172,6 +183,15 @@ async def junit_upload(
172183
envvar=["GITHUB_BASE_REF", "GITHUB_HEAD_REF", "GITHUB_REF_NAME", "GITHUB_REF"],
173184
callback=_process_tests_target_branch,
174185
)
186+
@click.option(
187+
"--test-exit-code",
188+
"-e",
189+
help="Exit code of the test runner process. Used to detect silent failures where the runner crashed but the JUnit report appears clean.",
190+
type=int,
191+
required=False,
192+
default=None,
193+
envvar="MERGIFY_TEST_EXIT_CODE",
194+
)
175195
@click.argument(
176196
"files",
177197
nargs=-1,
@@ -187,6 +207,7 @@ async def junit_process(
187207
test_framework: str | None,
188208
test_language: str | None,
189209
tests_target_branch: str,
210+
test_exit_code: int | None,
190211
files: tuple[str, ...],
191212
) -> None:
192213
await junit_processing_cli.process_junit_files(
@@ -197,6 +218,7 @@ async def junit_process(
197218
test_language=test_language,
198219
tests_target_branch=tests_target_branch,
199220
files=files,
221+
test_exit_code=test_exit_code,
200222
)
201223

202224

mergify_cli/ci/junit_processing/cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async def process_junit_files(
2727
test_language: str | None,
2828
tests_target_branch: str,
2929
files: tuple[str, ...],
30+
test_exit_code: int | None = None,
3031
) -> None:
3132
# ── Header ──
3233
click.echo(SEPARATOR)
@@ -171,6 +172,25 @@ async def process_junit_files(
171172
# ── Quarantine ──
172173
_print_quarantine_section(result, error=quarantine_error)
173174

175+
# ── Silent failure detection ──
176+
if test_exit_code is not None and test_exit_code != 0 and nb_failing_spans == 0:
177+
click.echo("")
178+
click.echo(SEPARATOR_LIGHT)
179+
click.echo("")
180+
click.echo(
181+
f" ⚠️ Test runner exited with an error (exit code: {test_exit_code})",
182+
)
183+
click.echo(" but no test failures appear in the JUnit report.")
184+
click.echo(" The report may be incomplete — check your test runner logs.")
185+
click.echo("")
186+
click.echo(SEPARATOR)
187+
click.echo(
188+
"❌ FAIL — test runner exited with an error but no failures were reported",
189+
)
190+
click.echo(" Exit code: 1")
191+
click.echo(SEPARATOR)
192+
sys.exit(1)
193+
174194
# ── Verdict ──
175195
nb_quarantined_failures = len(result.failing_spans) if result is not None else 0
176196
click.echo("")

mergify_cli/tests/ci/junit_processing/test_cli.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async def _run_process(
5555
quarantine_result: quarantine.QuarantineResult | None = None,
5656
quarantine_side_effect: Exception | None = None,
5757
upload_side_effect: Exception | None = None,
58+
test_exit_code: int | None = None,
5859
capsys: pytest.CaptureFixture[str],
5960
) -> ProcessResult:
6061
quarantine_mock = mock.AsyncMock()
@@ -93,6 +94,7 @@ async def _run_process(
9394
test_language=None,
9495
tests_target_branch="main",
9596
files=files,
97+
test_exit_code=test_exit_code,
9698
)
9799

98100
captured = capsys.readouterr()
@@ -792,3 +794,154 @@ async def test_no_test_cases_in_junit(
792794
" Exit code: 1\n"
793795
"══════════════════════════════════════════\n"
794796
)
797+
798+
799+
# ── Silent failure detection (--test-exit-code) ──
800+
801+
802+
async def test_test_exit_code_not_provided_all_pass(
803+
capsys: pytest.CaptureFixture[str],
804+
) -> None:
805+
"""Backwards compat: no --test-exit-code flag, all tests pass -> exit 0."""
806+
result = await _run_process(
807+
files=(str(REPORT_ALL_PASS_XML),),
808+
quarantine_result=quarantine.QuarantineResult(
809+
failing_spans=[],
810+
quarantined_spans=[],
811+
non_quarantined_spans=[],
812+
failing_tests_not_quarantined_count=0,
813+
),
814+
test_exit_code=None,
815+
capsys=capsys,
816+
)
817+
818+
assert result.exit_code == 0
819+
assert "all tests passed" in result.stdout
820+
821+
822+
async def test_test_exit_code_zero_all_pass(
823+
capsys: pytest.CaptureFixture[str],
824+
) -> None:
825+
"""--test-exit-code 0, all tests pass -> exit 0."""
826+
result = await _run_process(
827+
files=(str(REPORT_ALL_PASS_XML),),
828+
quarantine_result=quarantine.QuarantineResult(
829+
failing_spans=[],
830+
quarantined_spans=[],
831+
non_quarantined_spans=[],
832+
failing_tests_not_quarantined_count=0,
833+
),
834+
test_exit_code=0,
835+
capsys=capsys,
836+
)
837+
838+
assert result.exit_code == 0
839+
assert "all tests passed" in result.stdout
840+
841+
842+
async def test_test_exit_code_nonzero_with_failures(
843+
capsys: pytest.CaptureFixture[str],
844+
) -> None:
845+
"""--test-exit-code 1 with failures in JUnit -> normal quarantine flow."""
846+
result = await _run_process(
847+
quarantine_result=quarantine.QuarantineResult(
848+
failing_spans=[FAILING_SPAN],
849+
quarantined_spans=[],
850+
non_quarantined_spans=[FAILING_SPAN],
851+
failing_tests_not_quarantined_count=1,
852+
),
853+
test_exit_code=1,
854+
capsys=capsys,
855+
)
856+
857+
assert result.exit_code == 1
858+
assert "0/1 failures quarantined" in result.stdout
859+
assert "test runner exited with an error" not in result.stdout
860+
861+
862+
async def test_test_exit_code_nonzero_no_failures(
863+
capsys: pytest.CaptureFixture[str],
864+
) -> None:
865+
"""--test-exit-code 1 with no failures -> silent failure detected, exit 1."""
866+
result = await _run_process(
867+
files=(str(REPORT_ALL_PASS_XML),),
868+
quarantine_result=quarantine.QuarantineResult(
869+
failing_spans=[],
870+
quarantined_spans=[],
871+
non_quarantined_spans=[],
872+
failing_tests_not_quarantined_count=0,
873+
),
874+
test_exit_code=1,
875+
capsys=capsys,
876+
)
877+
878+
assert result.exit_code == 1
879+
assert result.stdout == (
880+
"══════════════════════════════════════════\n"
881+
" 🚀 CI Insights\n"
882+
"\n"
883+
" Uploads JUnit test results to Mergify CI Insights and evaluates\n"
884+
" quarantine status for failing tests. This step determines the\n"
885+
" final CI status — quarantined failures are ignored.\n"
886+
" Learn more: https://docs.mergify.com/ci-insights/quarantine\n"
887+
"══════════════════════════════════════════\n"
888+
"\n"
889+
" Run ID: 00000002dfdc1c3e\n"
890+
" ☁️ 1 report uploaded\n"
891+
" 🧪 2 tests (0 failures)\n"
892+
"\n"
893+
"──────────────────────────────────────────\n"
894+
"\n"
895+
" ⚠️ Test runner exited with an error (exit code: 1)\n"
896+
" but no test failures appear in the JUnit report.\n"
897+
" The report may be incomplete — check your test runner logs.\n"
898+
"\n"
899+
"══════════════════════════════════════════\n"
900+
"❌ FAIL — test runner exited with an error but no failures were reported\n"
901+
" Exit code: 1\n"
902+
"══════════════════════════════════════════\n"
903+
)
904+
905+
906+
async def test_test_exit_code_oom_kill_no_failures(
907+
capsys: pytest.CaptureFixture[str],
908+
) -> None:
909+
"""--test-exit-code 137 (OOM) with no failures -> shows exit code 137 in warning."""
910+
result = await _run_process(
911+
files=(str(REPORT_ALL_PASS_XML),),
912+
quarantine_result=quarantine.QuarantineResult(
913+
failing_spans=[],
914+
quarantined_spans=[],
915+
non_quarantined_spans=[],
916+
failing_tests_not_quarantined_count=0,
917+
),
918+
test_exit_code=137,
919+
capsys=capsys,
920+
)
921+
922+
assert result.exit_code == 1
923+
assert "exit code: 137" in result.stdout
924+
assert (
925+
"test runner exited with an error but no failures were reported"
926+
in result.stdout
927+
)
928+
929+
930+
async def test_test_exit_code_nonzero_with_quarantined_failures(
931+
capsys: pytest.CaptureFixture[str],
932+
) -> None:
933+
"""--test-exit-code 1 with all failures quarantined -> quarantine logic applies, exit 0."""
934+
result = await _run_process(
935+
quarantine_result=quarantine.QuarantineResult(
936+
failing_spans=[FAILING_SPAN],
937+
quarantined_spans=[FAILING_SPAN],
938+
non_quarantined_spans=[],
939+
failing_tests_not_quarantined_count=0,
940+
),
941+
test_exit_code=1,
942+
capsys=capsys,
943+
)
944+
945+
assert result.exit_code == 0
946+
assert "failures quarantined, CI status unaffected" in result.stdout
947+
assert "test runner exited with an error" not in result.stdout

0 commit comments

Comments
 (0)