Emit JUnit XML from tests + mutation, consume it in CI
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 3m10s
libakerror CI Build / mutation_test (push) Successful in 6m58s

Produce machine-readable results and surface them in the Gitea pipeline:

- ctest: run with --output-junit to write ctest-junit.xml. The path must be
  absolute ("$(pwd)/...") because --output-junit otherwise resolves relative to
  the --test-dir build directory.
- mutation_test.py: new --junit FILE option writes a JUnit report where each
  mutant is a test case and a surviving mutant is a <failure> (so gaps show up
  as failing tests).
- .gitea/workflows/ci.yaml: both jobs generate their XML and feed it to
  mikepenz/action-junit-report with `if: always()`, so results publish even
  when a gate fails. Mutation publishing is display-only (fail_on_failure:
  false); the --threshold flag remains the gate.
- .gitignore: ignore the generated *-junit.xml artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 20:51:28 -04:00
parent 536a269aad
commit 43516c7e73
4 changed files with 88 additions and 4 deletions

View File

@@ -13,13 +13,23 @@ jobs:
run: | run: |
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y cmake gcc moreutils sudo apt-get install -y cmake gcc moreutils
- name: build and test - name: build and install
run: | run: |
mkdir installdir mkdir installdir
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=installdir cmake -S . -B build -DCMAKE_INSTALL_PREFIX=installdir
cmake --build build cmake --build build
cmake --install build cmake --install build
cmake --build build --target test # --output-junit resolves relative to the test dir, so give an absolute
# path to land the report in the workspace root for the reporter below.
- name: test (JUnit)
run: ctest --test-dir build --output-on-failure --output-junit "$(pwd)/ctest-junit.xml"
- name: publish test results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'ctest-junit.xml'
check_name: 'ctest results'
fail_on_failure: 'true'
- run: echo "🍏 This job's status is ${{ job.status }}." - run: echo "🍏 This job's status is ${{ job.status }}."
mutation_test: mutation_test:
@@ -38,5 +48,14 @@ jobs:
# deeper (slower) coverage including the macro header. # deeper (slower) coverage including the macro header.
- name: mutation testing - name: mutation testing
run: | run: |
python3 scripts/mutation_test.py --target src/error.c --threshold 65 python3 scripts/mutation_test.py --target src/error.c --junit mutation-junit.xml --threshold 65
# Publish even when the threshold gate fails, so survivors are visible.
# Display-only (fail_on_failure: false); the --threshold above is the gate.
- name: publish mutation results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'mutation-junit.xml'
check_name: 'mutation results'
fail_on_failure: 'false'
- run: echo "🍏 This job's status is ${{ job.status }}." - run: echo "🍏 This job's status is ${{ job.status }}."

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
include/akerror.h include/akerror.h
build/ build/
*-junit.xml

View File

@@ -234,6 +234,48 @@ class Runner:
return "survived" if t.returncode == 0 else "killed-test" return "survived" if t.returncode == 0 else "killed-test"
def _xml_escape(s):
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
def write_junit(path, records, targets):
"""Write a JUnit XML report. One <testcase> per mutant; a surviving mutant
is a <failure> (test-suite gap), a killed mutant is a passing case."""
by_file = {t: [] for t in targets}
for m, result in records:
by_file.setdefault(m.path, []).append((m, result))
total = len(records)
total_fail = sum(1 for _, r in records if r == "survived")
out = ['<?xml version="1.0" encoding="UTF-8"?>',
f'<testsuites name="mutation" tests="{total}" failures="{total_fail}">']
for f, items in by_file.items():
if not items:
continue
fails = sum(1 for _, r in items if r == "survived")
out.append(f' <testsuite name="mutation:{_xml_escape(f)}" '
f'tests="{len(items)}" failures="{fails}">')
for m, result in items:
name = _xml_escape(m.describe())
cls = "mutation." + _xml_escape(m.path)
if result == "survived":
detail = _xml_escape(f"{m.before.strip()} -> {m.after.strip()}")
out.append(f' <testcase name="{name}" classname="{cls}" time="0">')
out.append(f' <failure message="survived mutant '
f'({_xml_escape(m.op)} at {_xml_escape(m.path)}:'
f'{m.lineno})">{detail}</failure>')
out.append(' </testcase>')
else:
out.append(f' <testcase name="{name}" classname="{cls}" '
f'time="0"><system-out>{_xml_escape(result)}'
'</system-out></testcase>')
out.append(' </testsuite>')
out.append('</testsuites>')
with open(path, "w") as fh:
fh.write("\n".join(out) + "\n")
def copy_tree(src, dst): def copy_tree(src, dst):
ignore = shutil.ignore_patterns("build", ".git", "*.o", "*.so", "*~", ignore = shutil.ignore_patterns("build", ".git", "*.o", "*.so", "*~",
"#*#", "*.iso", "*.png") "#*#", "*.iso", "*.png")
@@ -265,6 +307,8 @@ def main():
ap.add_argument("--work", default=None) ap.add_argument("--work", default=None)
ap.add_argument("--timeout", type=int, default=120) ap.add_argument("--timeout", type=int, default=120)
ap.add_argument("--threshold", type=float, default=0.0) ap.add_argument("--threshold", type=float, default=0.0)
ap.add_argument("--junit", default=None,
help="write a JUnit XML report to this path")
ap.add_argument("--max-mutants", type=int, default=0, ap.add_argument("--max-mutants", type=int, default=0,
help="cap the run at N evenly-sampled mutants (0 = all)") help="cap the run at N evenly-sampled mutants (0 = all)")
ap.add_argument("--list", action="store_true") ap.add_argument("--list", action="store_true")
@@ -330,6 +374,7 @@ def main():
# Group mutants by file so we mutate one file at a time and restore it. # Group mutants by file so we mutate one file at a time and restore it.
killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0} killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0}
survivors = [] survivors = []
records = []
total = len(all_mutants) total = len(all_mutants)
# Cache pristine contents per target. # Cache pristine contents per target.
@@ -345,6 +390,7 @@ def main():
finally: finally:
write_lines(tgt_abs, pristine[m.path]) # always restore write_lines(tgt_abs, pristine[m.path]) # always restore
records.append((m, result))
if result == "survived": if result == "survived":
survivors.append(m) survivors.append(m)
mark = "SURVIVED" mark = "SURVIVED"
@@ -370,6 +416,11 @@ def main():
for m in survivors: for m in survivors:
print(" " + m.describe()) print(" " + m.describe())
if args.junit:
junit_path = os.path.abspath(args.junit)
write_junit(junit_path, records, targets)
print(f"\nJUnit report written to: {junit_path}")
if not args.keep and not args.work: if not args.keep and not args.work:
shutil.rmtree(work_parent, ignore_errors=True) shutil.rmtree(work_parent, ignore_errors=True)
else: else:

View File

@@ -42,7 +42,20 @@ cmake --build build --target mutation
Useful flags: `--timeout SECONDS` (per-suite build+test cap; a mutant that Useful flags: `--timeout SECONDS` (per-suite build+test cap; a mutant that
hangs is counted as killed), `--keep` (retain the scratch copy for debugging), hangs is counted as killed), `--keep` (retain the scratch copy for debugging),
`--work DIR` (use a specific scratch directory). `--work DIR` (use a specific scratch directory), `--junit FILE` (write a JUnit
XML report — surviving mutants appear as failing test cases).
## CI reporting
Both the unit tests and the mutation run emit JUnit XML that CI consumes:
* `ctest --test-dir build --output-junit "$(pwd)/ctest-junit.xml"` — note the
absolute path; `--output-junit` otherwise resolves relative to the test dir.
* `scripts/mutation_test.py --junit mutation-junit.xml`
`.gitea/workflows/ci.yaml` runs both and feeds the XML to
`mikepenz/action-junit-report` (with `if: always()`, so results publish even
when a gate fails). The generated `*-junit.xml` files are git-ignored.
## Mutation operators ## Mutation operators