Files
firmware/bin/check-test-attribution.py
Thomas Göttgens 9850b76351 ci(test): shard the native test suite across a matrix (#11706)
* ci(test): shard the native test suite across a matrix

Replace the single sequential runner with a matrix populated by
bin/test-shards.py from the test/ tree: areas over --max-suites are
split, smaller ones packed, and a --max-shards budget bounds the
fan-out. A collector job merges the per-shard JUnit reports, checks the
union against the canonical suite set, and states the verdict. Native
PlatformIO Tests remains as the single required check over the matrix.

Drop the --without-testing warm build. PlatformIO links every native
test program to the same $BUILD_DIR/$PROGNAME, so the area run relinked
each suite regardless. ccache carries the shared src objects between
shards instead; one shard is flagged cache_writer so a single entry is
saved.

The coverage-event-policy and coverage-channel-table envs and the
attribution canary move into their own matrix rows and job.

Harden the new paths: bound the matrix row count so a branch cannot size
the fan-out, reject multi-line or empty $GITHUB_OUTPUT values, fail the
whole-run attribution gate on an empty expected set, upload exact report
and tracefile names instead of globs, and pass the repo path to
bin/lib/shuffle.sh as an argument rather than into bash -c source text.

12 shards, largest 9 suites.

* ci(test): minimal test toolchain, cap shard runtime, fix pack overflow

Add .github/actions/setup-native-test, used by the shard and canary jobs
in place of setup-native. It drops the redundant second checkout, both
submodules (src/mesh/generated is tracked, meshtestic is the hardware
harness), cppcheck, and the adafruit-nrfutil, poetry and meshtastic pip
installs, and folds in ccache and lcov. setup-base and setup-native are
unchanged, so the firmware matrix and every other consumer keep theirs.

Cap the shard job at 30 minutes. A lost runner held one for 48 of the
360 GitHub allows by default, and there are twelve of them.

pack() could exceed --max-suites: ceil(total / cap) is a lower bound and
whole areas do not divide, so three areas of 6 at cap 10 put 12 in one
of two bins. Grow the bin count until every bin fits.

Validate the fixed-env test_filter tokens against SUITE_RE. PlatformIO
accepts globs there, and those tokens reach the same word-split and the
same attribution gate as discovered names. Split with read -ra so a
token cannot glob against the workspace either.

Report the suite count rather than the length of the -f argument array,
which counted every name twice.

Trim comments to the one or two lines AGENTS.md asks for.

* ci(test): quote the $GITHUB_OUTPUT redirects

Applied to all five, including the three that predate this branch, so the
file is consistent rather than half-converted.
2026-09-02 21:05:03 +00:00

170 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Verify each PlatformIO JUnit report ran the suite it claims to have run.
PlatformIO links every native test program to one path ($BUILD_DIR/$PROGNAME) and parses
Unity output textually, without checking that the reported source file belongs to the suite
it is running. Split a run into `--without-testing` then `--without-building` and every suite
executes whichever binary was linked last, all reporting PASSED. This reads the JUnit reports
that run already produces and fails on the two shapes that hides:
MISATTRIBUTED - a test case whose source file lives outside the suite that reported it
EMPTY - a suite that was asked to run and produced no test cases at all
Usage:
check-test-attribution.py [--expect "s1 s2"]... [--label TEXT] REPORT.xml...
--expect names the suites the run was asked for (repeatable, whitespace- or `-f`-separated,
so a CI area string can be passed through verbatim). Omit it to check attribution only.
Exit: 0 clean, 1 findings, 2 bad usage / unreadable report.
"""
import argparse
import glob
import sys
import xml.etree.ElementTree as ET
def parse_expect(values):
"""Flatten repeated --expect values into a suite list, tolerating `-f suite` tokens."""
suites = []
for value in values or []:
for token in value.split():
if token == "-f":
continue
suites.append(token.removeprefix("-f"))
return [s for s in suites if s]
def suite_of(testsuite_name):
"""`coverage:test_foo` -> `test_foo`; a bare name is returned unchanged."""
return testsuite_name.split(":", 1)[1] if ":" in testsuite_name else testsuite_name
def owns(suite, source_file):
"""Report whether source_file sits inside the suite's own directory.
Matched on a whole path segment so `test_mesh` does not claim `test_mesh_module`, and
with a leading separator so absolute and relative paths behave the same.
"""
normalized = "/" + source_file.replace("\\", "/").lstrip("/")
return f"/{suite}/" in normalized
def collect(paths):
"""Map suite -> list of (case name, source file or None), merged across reports."""
cases = {}
for path in paths:
try:
# The input is a JUnit report PlatformIO wrote, not untrusted data, and defusedxml is
# not installed for this job. In CI the collector reads these back from artifacts
# rather than off the same disk that produced them, so what keeps that true is the
# upload step naming the one file the shard was told to write instead of globbing:
# a test suite can write to the workspace, and under a glob its own XML would ride
# along into the merged report. See .github/workflows/test_native.yml.
# nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse
root = ET.parse(path).getroot()
except (ET.ParseError, OSError) as exc:
sys.stderr.write(f"check-test-attribution: cannot read {path}: {exc}\n")
sys.exit(2)
# PlatformIO nests <testsuite> under <testsuites>; accept a bare <testsuite> too.
nodes = [root] if root.tag == "testsuite" else root.iter("testsuite")
for node in nodes:
suite = suite_of(node.get("name", ""))
if not suite:
continue
entries = cases.setdefault(suite, [])
for case in node.iter("testcase"):
entries.append((case.get("name", "?"), case.get("file")))
return cases
def main():
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("--expect", action="append", default=[])
parser.add_argument("--label", default="")
parser.add_argument("reports", nargs="+")
args = parser.parse_args()
# Expand globs ourselves: CI passes a pattern that may match nothing if a step was skipped,
# and a silent pass over zero reports is exactly the false green this script exists to stop.
paths = sorted({p for pattern in args.reports for p in glob.glob(pattern)})
if not paths:
sys.stderr.write(
"check-test-attribution: no JUnit reports matched %s\n"
% " ".join(args.reports)
)
return 2
cases = collect(paths)
expected = parse_expect(args.expect)
misattributed = [] # (suite, case name, source file)
unsourced = [] # (suite, case name)
for suite, entries in sorted(cases.items()):
for name, source in entries:
if source is None:
unsourced.append((suite, name))
elif not owns(suite, source):
misattributed.append((suite, name, source))
empty = [s for s in expected if not cases.get(s)]
label = f" [{args.label}]" if args.label else ""
total = sum(len(v) for v in cases.values())
print(
f"test attribution{label}: {len(paths)} report(s), "
f"{len([s for s, v in cases.items() if v])} suite(s) with cases, {total} case(s)"
)
if unsourced:
print("")
print("UNSOURCED - these cases carry no source file, so ownership cannot be proved:")
for suite, name in unsourced[:20]:
print(f" {suite}: case '{name}'")
if len(unsourced) > 20:
print(f" ... +{len(unsourced) - 20} more")
print(
"A report without file attributes is not evidence that the suites ran their own"
)
print(
"tests. Treat it as a finding rather than a pass: the JUnit format has changed, or"
)
print("the runner emitted cases it could not attribute.")
if misattributed:
print("")
print(
"MISATTRIBUTED - these suites reported test cases belonging to another suite."
)
print(
"The run executed one suite's binary under another suite's name; the named"
)
print(
"suites did NOT run. Check for --without-building in the test invocation."
)
for suite, name, source in misattributed[:20]:
print(f" {suite}: case '{name}' came from {source}")
if len(misattributed) > 20:
print(f" ... +{len(misattributed) - 20} more")
if empty:
print("")
print("EMPTY - these suites were asked to run and produced no test cases:")
for suite in empty:
print(f" {suite}")
if misattributed or empty or unsourced:
print("")
print(
"RESULT: test attribution FAILED"
f"{label} ({len(misattributed)} misattributed, {len(empty)} empty,"
f" {len(unsourced)} unsourced)"
)
return 1
print(f"RESULT: test attribution OK{label}")
return 0
if __name__ == "__main__":
sys.exit(main())