meshtastic-firmware/test
Tom de6b23190a
Some checks failed
CI / version (push) Failing after 5s
Daily Packaging / hook-copr (push) Has been cancelled
Daily Packaging / package-ppa (noble) (push) Has been cancelled
Daily Packaging / package-ppa (resolute) (push) Has been cancelled
Daily Packaging / package-ppa (stonking) (push) Has been cancelled
Daily Packaging / package-obs (push) Has been cancelled
Daily Packaging / docker-multiarch (push) Has been cancelled
Daily Packaging / package-ppa (jammy) (push) Has been cancelled
CI / setup (push) Has been cancelled
CI / build-debian-src (push) Has been cancelled
CI / MacOS (15) (push) Has been cancelled
CI / MacOS (26) (push) Has been cancelled
CI / Windows (2025) (push) Has been cancelled
CI / package-pio-deps-native-tft (push) Has been cancelled
CI / test-native (push) Has been cancelled
CI / build-wasm (push) Has been cancelled
CI / docker (alpine, native-tft, linux/arm64) (push) Has been cancelled
CI / docker (debian, native-tft, linux/arm64) (push) Has been cancelled
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
CI / ci-gate (push) Has been cancelled
CI / gather-artifacts (esp32) (push) Has been cancelled
CI / gather-artifacts (esp32c3) (push) Has been cancelled
CI / gather-artifacts (esp32c6) (push) Has been cancelled
CI / gather-artifacts (esp32s3) (push) Has been cancelled
CI / gather-artifacts (nrf52840) (push) Has been cancelled
CI / gather-artifacts (rp2040) (push) Has been cancelled
CI / gather-artifacts (rp2350) (push) Has been cancelled
CI / gather-artifacts (stm32) (push) Has been cancelled
CI / firmware-size-report (push) Has been cancelled
CI / size-budget-gate (push) Has been cancelled
CI / release-artifacts (push) Has been cancelled
CI / release-firmware (esp32) (push) Has been cancelled
CI / release-firmware (esp32c3) (push) Has been cancelled
CI / release-firmware (esp32c6) (push) Has been cancelled
CI / release-firmware (esp32s3) (push) Has been cancelled
CI / release-firmware (nrf52840) (push) Has been cancelled
CI / release-firmware (rp2040) (push) Has been cancelled
CI / release-firmware (rp2350) (push) Has been cancelled
CI / release-firmware (stm32) (push) Has been cancelled
CI / publish-firmware (push) Has been cancelled
CI / publish-nightly (push) Has been cancelled
Test suite rebuild (#11322)
* docs(nodedb): make the native node cap unambiguous

The native node cap was stated in four places that disagreed, and the disagreement
already caused a wrong diagnosis: a saturated 200-node database looked arithmetically
impossible because the cap had been read as 248, computed from a header that does not
apply on this platform. The real value is 198.

On portduino MAX_NUM_NODES is not a compile-time constant at all - the variant defines
it as `portduino_config.MaxNodes`, resolved at runtime, default 200 and settable per
host with `General: MaxNodes`. variant.h is reached before mesh-pb-constants.h, so that
header's ARCH_PORTDUINO branch never fires and its plausible-looking 250 is dead code.

- #error-guard the dead branch rather than leave a wrong number where people grep. The
  guard found a real defect: seven translation units reach mesh-pb-constants.h without
  configuration.h (SerialConsole.cpp, StreamAPI.cpp, PacketAPI.cpp, ServerAPI.cpp,
  PiWebServer.cpp, ServiceEnvelope.cpp, MeshtasticOTA.cpp, and test/TestUtil.cpp), so
  each was compiling with a different MAX_NUM_NODES - and therefore a different
  PACKETHISTORY_MAX - than the rest of the build. Each now includes configuration.h
  first. It cannot be included from mesh-pb-constants.h itself: that reaches
  SerialConsole.h through DebugConfiguration.h and closes a cycle.
- Name the bare 250 in getMaxNodesAllocatedSize() NODEDB_MIGRATION_LOAD_CEILING. It is a
  decode allowance for files written by larger-cap firmware, not a cap, and it read like
  one.
- Fix docs/node_info_stores.md, which named the wrong source and a "10-250" range that
  is wrong for native, and the copilot-instructions tunables line that said "portduino
  250".

* test(harness): give each suite its own scratch HOME and report leftovers

Native suites shared one directory. Every suite that constructs a NodeDB loads and
saves ~/.portduino/default/prefs/ - nodes.proto, config.proto, channels.proto,
module.proto, device.proto, warm.dat, transmit_history.dat - and nothing cleared it,
so state leaked suite -> suite within a run and run -> every run after it. A test run
could also rewrite a real meshtasticd node database on the same machine.

Per-run isolation does not fix this: the leak is generated inside a single run, so the
boundary has to be per suite.

bin/pio-test-isolate.sh runs each suite in its own scratch $HOME, registered as
test_testing_command for env:native and env:coverage so a bare `pio test` and CI get the
same boundary, not just bin/run-tests.sh. It runs the binary unchanged and exits with its
exit code, so PlatformIO's pass/fail is untouched. Overriding HOME here rather than
around `pio` also sidesteps the blocker that a bare HOME= breaks pio's own
~/.platformio/penv/bin/pio lookup.

Leftovers are reported as a second axis, PASS/FAIL x CLEAN/DIRTY, because an unintended
write has no matching assertion by definition - nobody writes TEST_ASSERT for a save they
do not know is happening. The harness asserts it from outside, so it applies to every
suite without the author opting in.

- Only the *set of changed paths* is asserted, never contents. Hashes answer the boolean
  "did this change?" and nothing more; content baselines over protobuf bytes would churn
  on every NodeInfoLite field added, which is how snapshot suites become noise.
- Deliberate writes are declared in test/state-manifest.tsv - one central file, suite /
  flags / mandatory reason. run-tests.sh prints the opt-out count on every run.
- Granularity follows the state flag, so the two ship together: per-test by default
  (TestUtil redefines RUN_TEST to checkpoint after each test, naming the exact test that
  dirtied things), suite boundary for state=per-suite, where carrying state across test
  cases is the declared behaviour.
- A declared write that does NOT happen is reported as MISSING, not folded into DIRTY. It
  catches silently broken persistence; a warning for now, since some are conditional.
- Graded AMBER, not RED. With isolation in place DIRTY means "undeclared", not
  "dangerous", and a check that lands red on day one gets switched off.

Guard the guard, both halves: state_assert_empty() refuses to run a suite against a
sandbox that is not empty (otherwise the after-diff measures against the wrong baseline
and reports CLEAN while meaning nothing), and bin/test-state-check.sh drives the real
wrapper with fixtures asserting CLEAN / CLEAN / DIRTY / MISSING plus both directions of
the empty assertion. A checker that silently matches everything would otherwise pass
forever.

--write-manifest proposes entries for a human to paste and justify; it never applies
them, and neither does CI.

* test(harness): stop reporting Unity's exit code as a signal

A native suite ends in exit(UNITY_END()), and UNITY_END() returns the failure count.
PlatformIO's native runner reads that non-zero exit code as a POSIX signal number, so
four failures print "Program received signal SIGILL", five print "SIGTRAP", and the suite
is classified [ERRORED] rather than [FAILED].

There is no crash. The signal name tracks the failure count and nothing else - it moved
SIGILL -> SIGTRAP when a diagnostic probe added a fifth failure - and it cost hours of
hunting a memory bug that did not exist, on an env (native) that carries no sanitizer at
all. It also explains the phantom extra test case in the totals: the runner adds a
synthetic entry for the signal it thinks it saw.

run-tests.sh now says so inline whenever a signal line appears, and the three
agent-facing docs say it too.

* test(admin): isolate NodeDB and globals per test

setUp() did `if (!nodeDB) nodeDB = new NodeDB();` and never deleted it, so 83 of the 85
tests shared one never-reset database and never restored config, owner, devicestate or
channelFile. The fixture that does restore them was opt-in and armed by exactly two
tests. The setUp comment claiming the rest "set their own config/region state and are
unaffected" was not true - the admin handlers under test write all four globals.

Route every test through the fixture instead: setUp saves the globals and installs a
fresh NodeDB, tearDown restores and deletes it. The two tests that armed it themselves no
longer need to.

All 85 pass, so nothing was silently relying on the shared state. It costs about 7% of
the suite's runtime (a NodeDB construction is a loadFromDisk plus, with a region set, key
generation) - worth paying to write the phase 3 tests against a clean fixture rather than
83 tests' residue.

Also cap the per-test attribution in the run summary at five entries; the full list stays
in the suite's sandbox.

* test(fs): cover the bounded file-manifest walk

getFiles() runs on every phone sync via STATE_SEND_FILEMANIFEST, and nothing asserted any
of its bounding behaviour. It does execute unasserted from test_stream_api's handshakes,
but the cap, the depth limit, the wasLimited paths, overlong-path rejection and capacity
release were all unguarded.

Eight tests, all describing what the code does today: today's code is already correct
here, since #10778 landed the by-reference collectFiles(), the 64-entry cap, the strlcpy
bounds and the swap-idiom release. They pass on arrival, which is the point - this is the
baseline a later change has to leave alone.

Two things they do not cover, and cannot:

- Moving reserve() outside the __cpp_exceptions guard. Exceptions are on natively, so the
  #else branch is not compiled. The suite's job there is to prove that change alters
  nothing observable.
- The file.name() null guard. No in-tree backend returns null; the guard is defensive.

The manifest-release test pins the swap idiom rather than calling
PhoneAPI's releaseFilesManifest(), which is file-local. It asserts capacity() == 0, not
just size() == 0 - a size-only check passes on clear(), which is the bug #7924 shipped.

Suite count 43 -> 44, recounted against the directories rather than copied.

* test(admin): assert node-DB metadata saves skip the radio reload

set_favorite_node, set_ignored_node and toggle_muted_node each persist a NodeInfoLite bit
and nothing else. MeshService::reloadConfig() gates its region re-derivation and
configChanged notification on saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS), so a
SEGMENT_NODEDATABASE-only save already skips the live radio reconfigure.

Pure characterization - all three pass on develop. Worth pinning because that reconfigure
is the path implicated in the WisMesh Tag favourite-node crash, and develop asserts
nothing about it: widening the saveWhat mask or reordering the check would currently go
unnoticed.

Ported from the config-save series along with ConfigChangedCounter (an Observer<void *>
counting configChanged notifications, the only externally visible signal that the reload
branch was taken) and TEST_NODE_NUM. They join the existing suite, so no suite-count
change.

* refactor(menu): extract the mute toggle into a named function

The node menu's mute action was inline in a banner-callback lambda, and that lambda only
ever runs via screen->showOverlayBanner() - which is why nothing in MenuHandler.cpp was
reachable from a test. Lift the `selected == Mute` branch into
menuHandler::toggleNodeMuted(uint32_t) and call it from the lambda.

Behaviour-neutral by construction: same statements, same order, same bare saveToDisk().
The null check moves into the function, so the call site no longer needs its own lookup.
Verified by the native build and suite; the byte-identical-image check on a
headroom-constrained nRF52 board was not run locally - CI's firmware-size comment covers
it.

Three tests come with it, all describing today's behaviour:

- the bit flips both ways and no configChanged fires (develop never calls reloadConfig on
  this path);
- an unknown node is a no-op rather than a write;
- and the segment mask. Flipping one NodeInfoLite bit currently rewrites all five
  segments via bare saveToDisk(). That is asserted deliberately, with the comment naming
  it as characterization of a known defect: a pending fix narrows it to
  SEGMENT_NODEDATABASE, and when it lands this assertion is expected to change, which
  makes the improvement visible in the diff instead of silent.

saveToDisk() is not virtual, so the mask is observed through its effect - remove the five
prefs files, toggle, and see which reappear.

* docs(test): make every suite count a pointer to the canonical one

test/native-suite-count is the registered total and is machine-checked against test/test_*
on every full run and by the suite-count-check CI job. Every other statement of the count
is a copy that drifts: copilot-instructions said 12, AGENTS.md said 19, and the real
number is 44.

Replace both literals with a pointer to the file, say explicitly that no document should
state the count as a literal, and reframe the two suite listings as descriptions rather
than inventories - they carry per-suite information the count does not, so they stay, but
nothing should infer completeness from their length. Register the new FS suite in both.

* test(harness): randomise suite order, reproducibly

Landed last, deliberately. Randomising an order-dependent suite set does not find bugs so
much as convert a silent pass into intermittent red, and the first instinct is to revert
the randomisation rather than fix the coupling. Phases 1-2 removed the coupling; this
keeps it removed.

Both runners previously hid order dependence behind a fixed order that happened to differ
between them, and neither order was chosen: CI's area rules put admin first, PlatformIO's
local discovery is reverse alphabetical and put it last. CI was green by accident.

- bin/run-tests.sh --shuffle / --seed <n>. The seed defaults to HEAD's short SHA: one
  order per commit, so a red is replayable and attributable to the diff instead of flaky,
  while the project keeps exploring orders. Printed at the start and carried into the
  RESULT line, so a verdict is replayable from that line alone; the full order is printed
  on failure, because for an order-dependent failure the order is the diagnostic.
- The shuffle is a Fisher-Yates over a MINSTD generator rather than awk's rand(), whose
  sequence differs between gawk and mawk. A seed that does not reproduce the same order on
  another machine is not a seed.
- Shuffling needs one `pio test -f <suite>` invocation per suite - PlatformIO orders by
  its own os.walk() over test/ and filters only select - which measures at about 4.7s per
  suite of extra startup.
- CI shuffles its area order, seeded from GITHUB_SHA and printed with the command to
  replay it locally. Intra-area order stays PlatformIO's; controlling it there would mean
  per-suite invocations, which is a cost worth deciding separately.

Also records the 16 measured entries in test/state-manifest.tsv, each with its reason,
taken from a full run's --write-manifest output rather than guessed.

* test(default): cover the region-throttle interval overload

getConfiguredOrDefaultMsScaled(configured, default, nodes, TrafficType) is the overload
every telemetry and position module actually calls, and nothing referenced TrafficType
anywhere under test/. All four of its behaviours were unguarded: the no-region guard, the
throttle <= 1 short-circuit, the multiply, and the 64-bit overflow clamp.

The throttles are real, not hypothetical - EU_866 carries PROFILE_LITE, which sets both
positionThrottle and telemetryThrottle to 10, so a change here moves broadcast spacing in
that region by an order of magnitude.

Each test pins numOnlineNodes at the congestion threshold and uses ROUTER, which never
congestion-scales, so the coefficient is 1 and the throttle is the only variable. The
overflow case needs a base above INT32_MAX/10, hence three days rather than one.

* ci(test): keep pull-request suite order fixed, seed the rest

Shuffling the area order on every run - including pull_request - would turn a
contributor's PR red for an ordering they did not choose, which is how a randomisation
gets reverted instead of the coupling being fixed. That is the exact dynamic the ordering
work was sequenced last to avoid, and the previous commit walked straight into it.

- pull_request keeps the fixed declared area order.
- push and schedule shuffle, seeded from the commit SHA: deterministic per commit,
  printed, attributable, and never blocking someone else's PR.
- A suite_order_seed input on workflow_call and workflow_dispatch overrides both, so a
  specific failing order can be replayed anywhere, including on a PR.

The run log prints which mode it took, the resulting order, and the local command to
replay it.

* ci(test): satisfy CKV_GHA_7 and yamllint on the seed input

The seed is reachable through workflow_call, which callers can pass programmatically. The
workflow_dispatch copy tripped checkov's "workflow_dispatch inputs MUST be empty" rule,
and suppressing it was not worth it: replaying a specific order is a local operation, and
the run log already prints the exact bin/run-tests.sh command to do it.

* style(menu): apply the node-ID format convention

RadioInterface.cpp documents the rule: 0x%08x in logs, !%08x in user-facing
display. MenuHandler held every remaining exception - seven logs printing bare
%08X, and two display labels doing the same.

Repo-wide there are now no bare %08X node IDs left in log calls.

* ci(test): pass workflow inputs through env, not shell interpolation

suite_order_seed and github.event_name were spliced into the run: script as
${{ }} text, so a value carrying shell metacharacters would execute as code on
the runner rather than being read as data. semgrep (run-shell-injection) and
zizmor (template-injection) both flag it.

Both now arrive as environment variables and are read as "$VAR".

* refactor(test): share the seeded shuffle between the harness and CI

bin/run-tests.sh and test_native.yml each carried a byte-identical copy of the
MINSTD Fisher-Yates awk. The workflow prints "replay locally: ./bin/run-tests.sh
--shuffle --seed $seed" after a shuffled CI run, and that instruction is only
true while the two agree - drift would be announced by a replay quietly
reproducing a different order than the one that failed.

Extract shuffle_suites() to bin/lib/shuffle.sh and source it from both.
Permutations verified identical across seeds before and after the move.

* fix(test): correct the shared-state MISSING check and summary join

Three defects in the new harness:

state_classify() matched declarations two different ways - state_path_declared()
for "undeclared", a hand-rolled regex for "missing". Interpolating an entry into
an ERE also let a metacharacter in a manifest name match a file that is not the
declared one. Both directions now go through the one helper.

`paste -sd'; '` does not join with "; ": with -s, paste cycles through a
multi-character delimiter one character per join, so paths rendered as
"a;b c;d e". Replaced with an awk join.

test-state-check.sh ran on after a failed cd instead of stopping (SC2164).

./bin/test-state-check.sh: 6/6 fixtures pass, MISSING included.

* fix(portduino): bound General.MaxNodes

MaxNodes was validated only for <= 0. Any positive value, including a typo'd or
pasted-in one, propagates to MAX_NUM_NODES and scales both the node DB and the
nodes.proto decode ceiling - failing at boot with no obvious cause.

The ceiling is a sanity bound, not a capability limit; raise it if a host
genuinely needs more.

* docs(nodedb): reconcile the capacity tables

The property matrix omitted the ESP32-S3 100-node flash tier that the platform
table above it lists, and neither mentioned that the WASM build overrides
MaxNodes to 80 in wasm_config_apply().

* fix(nodedb): make mesh-pb-constants.h self-sufficient on portduino

The ARCH_PORTDUINO #error assumed it was unreachable in a normal build. It is
not: the vendored device-ui sources include this header without configuration.h,
which broke both native-tft docker builds.

Include configuration.h here instead, ahead of every compile-time default -
variant.h overrides MAX_RX_TOPHONE as well as MAX_NUM_NODES, so placing it lower
in the file just moves the divergence to a redefinition. The #error stays as a
backstop for the case where that include genuinely stops providing the cap.

Verified with the native env's own flags: a TU including only this header now
compiles, normal-order use of both macros compiles, and NodeDB.cpp compiles.

* fix(portduino): raise the MaxNodes ceiling to 16000

Marked artificial: nothing in the node DB fails at 16001. 16000 sits just under
the 16384 (128 x 128) population where HopScalingModule saturates its sampling
denominator and starts dropping nodes, so a host inside the bound still gets
meaningful hop recommendations.

* lint(trunk): advise on node IDs logged as bare %08x

RadioInterface.cpp documents the convention - 0x%08x in logs, !%08x in display -
but nothing enforced it, which is how the MenuHandler cluster drifted. 22 call
sites in PacketHistory, NodeInfoModule and PositionModule are still off it.

A trunk linter rather than a CI grep job, because trunk checks changed files:
new violations get flagged without a 22-site cleanup landing in an unrelated PR.
Modelled on the existing too-many-defined definition.

Scoped to values it can tell are IDs - an ID-shaped argument (->num, .from,
getNodeNum) or message text naming one. A 32-bit hex that is not an ID is out of
scope, so the CRC32 logs in ethOTA.cpp are correctly ignored.

Emits "note", trunk's only non-blocking level: "warning" and "info" both exit
non-zero and would gate CI, which is not what a log-format nit deserves. The
pre-existing sites are line-scoped in the allowlist, so a new bad call in those
same files is still caught.

* lint(trunk): stop exempting the known node-id-format sites

The seeded allowlist made the rule green by declaring the backlog acceptable.
Empty it instead, so the 22 pre-existing sites are reported and get cleaned up
by whoever next edits those files.

Costs nothing to do: the rule emits "note", so these are non-blocking either
way. The allowlist stays for its real purpose - a value the linter misreads as
an ID.

* style: log node and packet IDs as 0x%08x

Clears the 22 sites the node-id-format linter reports, so the rule starts from
zero rather than from a backlog nobody can see - trunk suppresses pre-existing
findings by default, so left alone these would not have surfaced on edit the way
an empty allowlist implies.

Format strings only; no argument or control flow changes. The !%08x
user-facing display forms are deliberately untouched - that is the other half of
the same convention.

* test(harness): build once up front, so suite timings mean something

run-tests.sh fused build and run in a single pio invocation, so whichever suite
PlatformIO's directory walk reached first absorbed the entire src compile and
reported it as its own duration. On a real run that made a 0.03s suite report
13m21s, and hid the build cost from every other number in the summary.

Do what .github/workflows/test_native.yml already does: one --without-testing
build pass, then run with --without-building. Measured on a full 44-suite run -
the build is now a single reported figure and 968 test cases execute in 1.9s,
with no suite above 0.084s.

Build output goes to its own log rather than $LOG: the outcome regexes match
"error:" and "[ERRORED]", so a compiler diagnostic sharing that file would read
as a test failure.

Both red paths now keep the log they quote from. $LOG and the build log are
mktemps the EXIT trap removes, so the three grepped lines were previously all
anyone ever saw - and the cause is usually further up than the first [FAILED].

* test(harness): keep the run log on every red path

bin/pio-test-isolate.sh already keeps a failing or DIRTY suite's sandbox and log
under .pio/test-state/<suite>/. What was missing is the cross-suite view: $LOG is
a mktemp the EXIT trap deletes, so run-tests.sh quoted three grepped lines from a
file that no longer existed by the time anyone looked.

Preserve it as .pio/build/<env>/test-failure.log from both red paths - including
"no success summary found", which said "see log" while preserving nothing, and
which is exactly the case where the build died before any suite ran and so left
no per-suite sandbox either.

Cleared at the start of every run, so a green run cannot leave a red one's log
lying around looking current.

* fix(test): report the real failure count on a shuffled red

A shuffled run is one `pio test` invocation per suite, all appending to the
same log, so the log carries one PlatformIO "N test cases:" summary per suite.
verdict_red() took `tail -1`, which reports whatever the LAST suite did: a
failure in suite 3 printed a "0 failed" summary from suite 44 directly under
"RED - failures detected:".

Sum the summaries instead. A single summary line - every unshuffled run - is
passed through verbatim, so the familiar output is byte-identical.

The patterns are passed to the awk helper as strings rather than /regex/
literals: awk evaluates a regex literal in argument position as `$0 ~ /re/`,
so the callee would receive 0 or 1 and silently sum garbage.

* fix(test): do not emit an empty suite name for an empty shuffle

`printf '%s\n' "$@"` with no arguments still writes one empty line, and both
callers read shuffle_suites through mapfile, so an empty suite list arrived as
a single suite named "". Return before the printf when there is nothing to
shuffle.

* test(harness): state and enforce the Linux host requirement

The native harness is a Linux tool: bash 4+ (mapfile), GNU coreutils and GNU
find (-printf, md5sum, -executable). Most of that predates this branch -
mapfile and both find predicates are already on develop - but none of it was
written down, so the requirement was there to be discovered rather than read.

Refuse to start on a non-Linux uname instead of degrading. On a BSD userland
this would not fail cleanly: it would mis-hash the sandbox and mis-read the
suite list, and still print a verdict. A state check that silently measures
the wrong thing is worse than one that declines to run.

Carrying a per-host fallback was the alternative, and it buys a second code
path that nothing in CI exercises. bin/test-native-docker.sh already exists
for macOS and non-Linux hosts, and the native-macos PlatformIO env is a build
target for meshtasticd, not a test host - the isolation wrapper is registered
for env:native and env:coverage only.

Documented in the script header, test/README.md, and both agent docs.

* fix(test): terminate every suite with exit(UNITY_END())

Two sites across two suites ended on a bare UNITY_END(). That ends the
reporting, not the suite: setup() returns, the runtime goes on calling loop(),
and the process runs forever. PlatformIO does not notice - it reports a suite
from its Unity output, not from process exit - so the suite passes, the run
goes green, and the binary stays resident. Thirteen of them had accumulated on
one dev box, the oldest 19 hours old.

The costs are quiet by construction:

- the per-suite sandbox is deleted underneath a live process, so its
  CLEAN/DIRTY verdict describes what the suite had written when the harness
  stopped looking, not what it left behind;
- .gcda coverage and LeakSanitizer's report both flush from atexit handlers,
  so a suite that never exits contributes no coverage and gets no leak check;
- each survivor pins its own deleted 94 MB binary, which du cannot see.

One of the two is the #else of an architecture guard, which is the easiest one
to get wrong - it looks like there is nothing to clean up. test_mqtt has a
correct exit(UNITY_END()) in its live branch, so a "does this file call exit()
anywhere" check passes the file whole.

test_serial had two more. develop's serial-config validation rework
restructured that suite - the architecture guard is gone and both remaining
branches now exit correctly - so this commit no longer has anything to change
there; bin/lint-unity-exit.sh, added later on this branch, is what keeps it
that way.

test/README.md gets a section on it, since the skeleton showing the right
shape had not stopped this happening.

* test(harness): detect and reap suites that outlive their run

A suite that never exits was invisible: PlatformIO reports a suite from its
Unity output, so the run stayed green while the binary kept running. Two
checks, because they fail differently.

Runtime, in bin/pio-test-isolate.sh: the sandbox $HOME is mktemp-unique per
suite, so any process still holding it is a survivor of that suite. Matching
on the environment rather than a remembered PID identifies one whatever its
parentage - a fork, a grandchild, a process already reparented to init - none
of which a $! comparison catches. Reaped before the after-fingerprint is
taken, so that fingerprint measures a tree nobody is still writing to, and so
a run cannot leave processes accumulating on the host. Recorded as a sixth
summary column and graded AMBER: the tests did pass, but the CLEAN verdict and
the coverage were measured under a false assumption.

Author-time, as bin/lint-unity-exit.sh, wired into trunk at "note" like
node-id-format: every UNITY_END() must be wrapped in exit(). The rule is per
occurrence, and that is the point - a file-level "calls exit() somewhere"
check passes test_serial and test_mqtt, which have a correct one in their live
branch and a bare one in the #else. Running it over the tree turned up
test_mqtt, which the file-level pass had missed.

It allows `int rc = UNITY_END(); ...; exit(rc)`, used by test_packet_signing
to restore globals between the summary and the exit. That is where the rule
gives ground: capturing and never exiting would leak and is not flagged.
Flagging a correct idiom would push someone to "fix" working code.

bin/test-state-check.sh gains a survivor fixture, asserting the wrapper both
reports and reaps - a detector that only reports leaves the host accumulating
processes, which is half the harm. 8/8.

* fix(lint): make the unity-exit scanner statement-aware

The rule judged one physical line at a time, which reports two kinds of correct
code as bare:

    /* a comment that happens to
       mention UNITY_END() */          <- interior lines were never stripped

    exit(
        UNITY_END());                  <- exit( and the macro never met

On a probe of both, two of three findings were wrong. This is a note-level rule
whose whole job is advice, and bin/lint-node-id-format.sh already says why that
matters: a false positive costs more than a miss. One that cries wolf gets
ignored, and the real finding goes with it.

Carry /* ... */ state across lines and accumulate logical statements before
testing, with a 12-line cap so one unclosed call cannot swallow the rest of the
file - the same structure lint-node-id-format.sh uses, so the two custom linters
in bin/ work alike rather than each having its own idea.

Verified both directions: the develop-era sources still produce the same four
findings, the fixed tree produces none, and a probe covering block-comment
interiors, wrapped exit(), line comments, return UNITY_END() and capture-then-
exit reports only the genuinely bare calls - including a complete block comment
followed by real bare code on the same line, which the state machine has to
keep live.

Reported by CodeRabbit on #11322.

* fix(lint): tokenise instead of pattern-matching, and self-test it

Second round of review findings on the same scanner, all confirmed by direct
test before changing anything. Six defects, one root cause: layered regexes
cannot tokenise C++.

False positives (correct code reported):
  - UNITY_END() inside a string literal read as code

False negatives (real leaks missed):
  - a string containing "/*" opened comment state and swallowed later lines
  - greedy .* removed everything between two block comments on one line,
    taking a bare call with it
  - myexit(UNITY_END()) matched the exit() exemption as a substring
  - x == UNITY_END() and total += UNITY_END() matched the assignment exemption

Replaced with a character-level scan carrying comment state, and token-bounded
exemptions: exit must be a whole identifier, and the capture form must be a
plain `=`. Raw string literals are still not modelled - there are none under
test/, and delimiter tracking for a case that does not occur would be untested
code guarding untested code, so it is documented rather than guessed at.

Also drops the `return UNITY_END()` exemption. It only terminates from main(),
there is no main() under test/, and from a helper it just returns a count.

bin/test-lint-unity-exit.sh pins all fifteen cases, every false positive and
false negative found in review among them. The rule has been wrong twice in a
way that looked fine by inspection; it needed a self-test more than it needed
another careful reading.

Two further findings in the same review:

  - bin/run-tests.sh dropped PASSTHRU in shuffled mode, so `--shuffle -vvv`
    built verbosely and then ran quietly. The shuffled loop now forwards
    EXTRA_ARGS, which is PASSTHRU minus the -f pair it supplies per suite.
  - bin/run-tests.sh did not guard `cd "$ROOT_DIR"`.

And one that did not reproduce: the survivor fixture's glob does find the pid
file (verified with the lookup instrumented - the earlier failure was an
artifact of running the script from /tmp, where SCRIPT_DIR cannot resolve).
The assertion was still weak, because an empty pid took the "not running"
branch and passed vacuously. It now fails if the pid was never recorded, and
finds the file by search rather than assuming a directory depth.

Reported by CodeRabbit on #11322.

* fix(lint): report each UNITY_END occurrence at its own location

The self-test only asked "did the linter say anything", so it could not have
caught a wrong line, a wrong column, or a missing second finding. Fixtures now
assert the exact diagnostics as line:col, and the first run of that assertion
found two real problems.

The caret pointed at the wrong occurrence. For `exit(UNITY_END()); UNITY_END();`
the verdict was right but the column was 17 - the wrapped call - because the
scanner stripped terminating forms out of the whole statement and then reported
the first occurrence it had seen. Two bare calls on one line reported once.

Judged per occurrence now, by looking back through whitespace at what wraps it,
so both the count and the caret are right. That also needed a position map from
strip_noncode(): removing a comment or collapsing a literal shifts every later
column, and counting occurrences in the raw line does not recover it either -
TEST_MESSAGE("... UNITY_END() ..."); UNITY_END(); has two occurrences in the raw
text and one in the code.

Four of the expected columns I wrote by hand were also wrong, off by one. The
linter was right in every case; the assertions were not. They are computed from
the fixture text now rather than pasted from output, because a baseline accepted
from the tool it is testing asserts nothing.

17 fixtures, including the two-on-one-line case from review and its mirror.

Reported by CodeRabbit on #11322.
2026-08-06 14:05:07 +00:00
..
fixtures Yaml check for Meshtasticd (#11224) 2026-07-30 11:06:24 +00:00
support Time out an abandoned admin edit transaction (#11254) 2026-07-27 15:22:33 +00:00
test_admin_radio Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
test_admin_session_repro test: cover coerced packet signature policy 2026-07-22 12:25:19 -07:00
test_atak Fix embedded null byte truncation in ATAK strings (#9570) 2026-02-08 11:20:15 -06:00
test_breakout Snake! (#10936) 2026-07-16 18:35:33 -05:00
test_chirpy Snake! (#10936) 2026-07-16 18:35:33 -05:00
test_crypto fix(crypto): hash full size_t inputs (#11359) 2026-08-06 11:21:56 +00:00
test_default Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
test_event_profile_storage feat: preserve normal radio profiles across event firmware (#11110) 2026-07-26 22:58:23 +00:00
test_fscommon_getfiles Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
test_fuzz_config Yaml check for Meshtasticd (#11224) 2026-07-30 11:06:24 +00:00
test_fuzz_decode More fuzz tests and small fixes for the findings (#10864) 2026-07-06 13:19:01 -05:00
test_fuzz_emotes More fuzz tests and small fixes for the findings (#10864) 2026-07-06 13:19:01 -05:00
test_fuzz_packets Phantom node fix perhaps (#11271) 2026-07-28 18:32:18 +00:00
test_hop_scaling More fuzz tests and small fixes for the findings (#10864) 2026-07-06 13:19:01 -05:00
test_http_content_handler MacOS fixes 2026-06-09 21:00:05 -05:00
test_mac_from_string emdashes begone (#10847) 2026-07-01 19:01:27 -05:00
test_mem_audit Add MemAudit: per-subsystem heap accounting in the boot log (#10900) 2026-07-06 13:18:49 -05:00
test_mesh_beacon Add MEDIUM_TURBO modem preset (#10988) 2026-07-11 08:24:35 -05:00
test_mesh_module Router: defer nested local sends to avoid handleReceived re-entrancy (#11191) 2026-07-24 19:30:48 +00:00
test_meshpacket_serializer Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
test_module_config fix(admin): persist TAK module config (team color / member role) (#11216) 2026-07-25 23:03:06 +00:00
test_mqtt Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
test_nexthop_routing Add configurable event mode hop limit (#11275) 2026-07-29 10:54:48 +00:00
test_nmea_wpl Fix the NMEA checksum offset and harden the buffer writes around it (#11293) 2026-07-31 08:20:56 +00:00
test_nodedb_blocked NodeDB: clear only the slots removeNodeByNum() vacates (#11022) 2026-07-16 21:48:25 +02:00
test_optin_migration feat(config): make position & telemetry broadcast opt-in (#10929) 2026-07-07 14:58:58 -05:00
test_packet_history emdashes begone (#10847) 2026-07-01 19:01:27 -05:00
test_packet_signing fix(router): clear failed reliable send retries (#11267) 2026-07-30 11:00:32 +00:00
test_pki_admin_fallback Budget the admin-key PKI decrypt fallback (#11100) 2026-07-21 09:48:09 +02:00
test_position_module Stream our own position to the phone/UI while mesh position sharing is opt-in (#11270) 2026-07-28 14:17:09 +00:00
test_position_precision Compute GeoCoord's UTM/MGRS/OSGR/OLC lazily instead of eagerly (#10996) 2026-07-13 20:04:24 -05:00
test_radio fix(lr2021): live LF↔HF reconfigure via full begin() (#11279) 2026-08-05 10:07:14 +00:00
test_rtc Enhance RTC handling with unit test support for system time fallback (#10642) 2026-06-24 16:49:29 -05:00
test_serial Serial config validation (#11339) 2026-08-05 06:55:31 +00:00
test_snake Snake! (#10936) 2026-07-16 18:35:33 -05:00
test_stream_api Arrival time fix perhaps (#11274) 2026-07-29 14:03:25 +00:00
test_tak_config fix(admin): persist TAK module config (team color / member role) (#11216) 2026-07-25 23:03:06 +00:00
test_traceroute_nexthop Corroborate traceroute next-hop updates against the relaying node (#11108) 2026-07-21 09:11:05 +02:00
test_traffic_management TMM & Warmstore key source naming (#11119) 2026-07-31 10:39:39 +00:00
test_transmit_history emdashes begone (#10847) 2026-07-01 19:01:27 -05:00
test_type_conversions Harden against crafted-packet crashes + adversarial fuzzing (#10862) 2026-07-02 16:50:49 -05:00
test_utf8 emdashes begone (#10847) 2026-07-01 19:01:27 -05:00
test_warm_store TMM & Warmstore key source naming (#11119) 2026-07-31 10:39:39 +00:00
test_xmodem Treat backslash as a path separator in XModem filename validation (#11085) 2026-07-20 10:46:33 +02:00
native-suite-count Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
README.md Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
state-manifest.tsv Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
TestUtil.cpp Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00
TestUtil.h Test suite rebuild (#11322) 2026-08-06 14:05:07 +00:00

Native Unit Tests - Authoring Guide

This directory contains C++ unit tests that run on the host machine via PlatformIO's native environment. Tests use the Unity framework.

Running Tests

Preferred: use bin/run-tests.sh - it defaults to the coverage env, cross-checks the number of suites that actually ran, and emits an unambiguous RED/AMBER/GREEN verdict:

./bin/run-tests.sh                          # all suites
./bin/run-tests.sh -f test_traffic_management  # single suite
./bin/run-tests.sh -f test_traffic_management > /tmp/test_out.txt 2>&1; tail -5 /tmp/test_out.txt

Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER, 3 = FILTERED.

The harness is Linux-only, by choice. bin/run-tests.sh and the per-suite isolation it drives need bash 4+ and GNU coreutils/find (find -printf, md5sum), and the script refuses to start anywhere else rather than degrade quietly - a shared-state check that silently mis-hashes a sandbox still prints a verdict, and that verdict would be worthless. The native-macos PlatformIO env is a build target for meshtasticd, not a test host; the isolation wrapper is registered for env:native and env:coverage only. On macOS or Windows, run the suite in a container: ./bin/test-native-docker.sh.

-f is not a gate. A filtered run can pass while a full run fails, because filtering removes the suites that create the state a later suite trips over. Iterate with -f; gate on a full run.

Sanitizers are per env. coverage (the default) has ASan/LSan; native has none, verified. -e native runs are not sanitized.

A signal name in the output is not a crash. exit(UNITY_END()) returns the failure count and PlatformIO renders it as a signal number (4 -> SIGILL, 5 -> SIGTRAP), reporting the suite [ERRORED]. Match it against the failure count before assuming a fault.

Suite order is randomisable, and reproducible. --shuffle runs the suites in a seeded random order; --seed <n> replays an exact one. The seed defaults to the commit SHA - one order per commit, so a red is replayable and attributable rather than flaky - and is printed at the start of the run and on the RESULT: line. On failure the full order is printed, because for an order-dependent failure the order is the diagnostic. A single green seed is not evidence of order independence; vary it.

./bin/run-tests.sh --shuffle              # seed from HEAD, printed
./bin/run-tests.sh --seed 2855893161      # replay that exact order

Randomisation costs one pio invocation per suite (about 4.7s each), because PlatformIO orders suites by its own directory walk and -f only selects.

Copilot interface note: When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run.

Raw pio test (no sanitizers, no verdict logic) - use when you need to override the env or inspect verbose Unity output:

# All test suites
pio test -e native

# Single suite
pio test -e native -f test_your_module

# Verbose (shows build errors in detail)
pio test -e native -f test_your_module -vvv

Never pipe through | tail -N to shorten output. PlatformIO prints build errors at the top of output and test results at the bottom; tail will show stale cached results from a prior successful build while hiding the compile error that caused the current run to fail.

Preferred pattern for raw pio - redirect to file, then grep:

# Redirect all output to a file; grep for errors and results after it exits
pio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
echo "exit: $?"
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
tail -15 /tmp/test_out.txt

Why: piping through | grep line-buffers the output and suppresses all progress until the process exits, making it look hung. The redirect approach lets the build stream normally while still giving you filtered results afterwards.

Viewing verbose test output without truncation (e.g. TEST_MESSAGE group headers):

/tmp/meshtastic-pio-venv/bin/python -m platformio test -e coverage --filter test_mesh_beacon -vv 2>&1 | grep -v "[[:space:]]SKIPPED$"

The -vv flag makes Unity emit INFO: lines from TEST_MESSAGE calls; piping through grep -v SKIPPED removes the noise from platform feature gates while keeping all PASS/FAIL/INFO lines visible.

externally-managed-environment error on Ubuntu/Debian:

If pio test fails immediately with error: externally-managed-environment, the system pio binary is using the OS Python which newer distros lock down. Use PlatformIO's own venv instead:

~/.platformio/penv/bin/python -m platformio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
tail -15 /tmp/test_out.txt

Helper Scripts (Useful Shortcuts)

These wrappers are handy when local host dependencies are missing or when you want repeatable commands.

# Run native tests in Docker (recommended on macOS / non-Linux hosts)
./bin/test-native-docker.sh

# Pass normal PlatformIO test args through to Dockerized test run
./bin/test-native-docker.sh -f test_your_module

# Force Docker image rebuild (after dependency changes)
./bin/test-native-docker.sh --rebuild

# Run simulator integration check (build native first)
pio run -e native && ./bin/test-simulator.sh

# Build and run meshtasticd natively
./bin/native-run.sh

# Build and run under gdbserver on localhost:2345
./bin/native-gdbserver.sh

# Build native release artifact into ./release/
./bin/build-native.sh native

Notes:

  • The repository script name is ./bin/test-simulator.sh (there is no test-native-simulator.sh).
  • ./bin/test-native-docker.sh is the closest match to CI behavior for native tests and avoids host package setup.

System Dependencies (Ubuntu/Debian)

The native build requires several system libraries. Install them all at once:

sudo apt-get install -y \
  libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \
  libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev

See .github/actions/setup-native/action.yml for the canonical list.

Creating a New Test Suite

1. Directory Structure

test/test_your_module/test_main.cpp

One file per suite. No per-test platformio.ini is needed - tests build under the [env:native] environment defined in the root platformio.ini.

2. File Skeleton

#include "MeshTypes.h"      // Include BEFORE TestUtil.h (provides NodeNum, etc.)
#include "TestUtil.h"        // initializeTestEnvironment(), testDelay()
#include <unity.h>

#if YOUR_FEATURE_GUARD       // Same #if guard as the module under test

#include "FSCommon.h"
#include "gps/RTC.h"
#include "mesh/NodeDB.h"
#include "modules/YourModule.h"
#include <cstdio>    // required for printf() - used for blank-line group separators
#include <cstring>
#include <memory>

// --- Test output helpers ---
// printf() writes directly to stdout and appears in -vv output as a plain line (no prefix).
// Use it for blank-line group separators: printf("\n");
// TEST_MESSAGE() emits a "file:line:INFO: <text>" line - visible at -vv and above.
// Use TEST_MSG_FMT for formatted diagnostic lines inside tests.
#define MSG_BUF_LEN 200
#define TEST_MSG_FMT(fmt, ...) do { \
    char _buf[MSG_BUF_LEN]; \
    snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
    TEST_MESSAGE(_buf); \
} while(0)

// --- Tests ---

void test_example()
{
    TEST_MESSAGE("=== Example test ===");
    TEST_ASSERT_TRUE(true);
}

// --- Unity lifecycle ---

void setUp(void) { /* runs before every test */ }
void tearDown(void) { /* runs after every test */ }

void setup()
{
    initializeTestEnvironment();   // MUST call - sets up RTC, OSThread, console
    UNITY_BEGIN();

    printf("\n=== Example group ===\n");           // header line to help find tests

    RUN_TEST(test_example);
    exit(UNITY_END());             // REQUIRED - a bare UNITY_END() leaves the process running
}

void loop() {}

#else // !YOUR_FEATURE_GUARD

void setUp(void) {}
void tearDown(void) {}

void setup()
{
    initializeTestEnvironment();
    UNITY_BEGIN();
    exit(UNITY_END());
}

void loop() {}

#endif

3. Terminate with exit(UNITY_END()), on every branch

A bare UNITY_END() does not end the suite - it ends the reporting. setup() returns, the runtime goes on calling loop(), and the process runs forever. PlatformIO does not notice: it reads the Unity summary off stdout, reports the suite PASSED and moves to the next one, so the run is green while the binary is still resident. Nothing surfaces it, and the leak is one process per suite per run.

The consequences are worse than an idle process:

  • The per-suite sandbox is deleted underneath a live process, so its CLEAN/DIRTY verdict says what the suite had written by the time the harness stopped looking, not what it left behind.
  • .gcda coverage data and LeakSanitizer's report are both flushed by atexit handlers, so a suite that never exits contributes no coverage and gets no leak check - silently.
  • Each survivor pins its own deleted binary on disk (~94 MB), which du cannot see.

So: exit(UNITY_END()) in every setup() branch, including the #else of a feature or architecture guard where the suite does nothing. The empty-suite branch is the easiest one to get wrong, because it looks like there is nothing to clean up.

4. Feature Guard

Wrap the entire test body in the same #if guard the module uses (e.g. #if HAS_VARIABLE_HOPS, #if !MESHTASTIC_EXCLUDE_GPS). When the feature is disabled, the #else branch produces an empty passing suite.

Common Patterns

MockNodeDB

Most module tests need to inject nodes with controlled hop distances and ages:

class MockNodeDB : public NodeDB
{
  public:
    void clearTestNodes()
    {
        testNodes.clear();
        numMeshNodes = 0;
    }

    void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops,
                     uint32_t ageSecs, bool viaMqtt = false)
    {
        meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
        node.num = num;
        node.has_hops_away = hasHops;
        node.hops_away = hopsAway;
        nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
        node.last_heard = getTime() - ageSecs;
        testNodes.push_back(node);
        meshNodes = &testNodes;
        numMeshNodes = testNodes.size();
    }

    std::vector<meshtastic_NodeInfoLite> testNodes;
};

static MockNodeDB *mockNodeDB = nullptr;

Set nodeDB = mockNodeDB; in setUp().

Test Shim (Exposing Protected/Private Members)

Subclass the module under test to make protected methods callable and private members writable:

class YourModuleTestShim : public YourModule
{
  public:
    // Pull protected methods into public scope via using.
    // IMPORTANT: using requires the method to be protected (or public) in the base -
    // friend alone does NOT satisfy this. See pitfall #6.
    using YourModule::runOnce;
    using YourModule::someProtectedMethod;

    // Wrap private members with setter methods (friend grants direct access here).
    void setPrivateField(int x) { privateField = x; }
};

For methods you want to expose via using, use the conditional access-specifier pattern in the header - not plain friend:

// In YourModule.h, inside the class body:
#ifdef PIO_UNIT_TESTING
  protected:
#else
  private:
#endif
    bool someMethod();

For private member variables that a shim setter needs to touch directly, friend is sufficient (no using involved):

// In YourModule.h, inside the class body:
#ifdef PIO_UNIT_TESTING
    friend class YourModuleTestShim;
#endif

Global Singleton Lifecycle

Most modules use a global pointer (extern YourModule *yourModule;). Manage it carefully:

void setUp(void) {
    // ... setup ...
}

void tearDown(void) {
    yourModule = nullptr;   // prevent dangling pointer between tests
}

void test_something() {
    auto shim = std::unique_ptr<YourModuleTestShim>(new YourModuleTestShim());
    yourModule = shim.get();
    // ... test ...
    yourModule = nullptr;
}

Pitfalls and How to Avoid Them

1. Persisted Filesystem State

You are handed a clean sandbox. Declare what you write.

Each suite runs inside its own scratch $HOME (bin/pio-test-isolate.sh), so state cannot reach the next suite. The files in play are wider than module state, and all but the last live under ~/.portduino/default/prefs/:

File Written by
nodes.proto any NodeDB save - including incidental ones from removeNodeByNum(), resetNodes(), nodeDBSelfCare(), and the constructor itself when the file is absent
config.proto, module.proto, channels.proto, device.proto config/channel saves, admin handlers
warm.dat WarmNodeStore::saveIfDirty(), on the node-DB save cadence
transmit_history.dat retransmission tracking
/prefs/<module>.bin per-module saveState()

NodeDB's constructor calls loadFromDisk(), so any suite that constructs one inherits whatever is there.

What you have to do:

  • Nothing, if your suite is self-contained. That is the default and what almost every suite wants.

  • If your suite mutates persisted state on purpose, add a line to test/state-manifest.tsv with a reason:

    test_nodedb_blocked	state=per-suite writes=nodes.proto,warm.dat	saturates the DB to test the protected-node cap
    

    An undeclared write is reported as DIRTY and grades the run AMBER. A declared write that never happens is reported as MISSING - a warning, and a useful one: it catches persistence that silently stopped working.

  • Use state=per-suite only if a test genuinely needs to observe the previous test's write (persistence round-trips, migration ladders). It relaxes per-test checking to the suite boundary, so make it a deliberate choice rather than an accident of setUp().

Deleting your own state in setUp() is still fine and still a good habit for intra-suite isolation - it is just no longer what stands between you and the next suite:

void setUp(void) {
    // ...
#ifdef FSCom
    FSCom.remove("/prefs/your_module.bin");
#endif
}

2. A Shared Fixture Is Not a Fixture

If your suite touches globals the code under test writes - nodeDB, config, owner, devicestate, channelFile - build and restore them in setUp/tearDown for every test, not just the ones that seem to need it. An opt-in fixture that only some tests arm leaves the rest sharing one never-reset object, and "the other tests set their own state and are unaffected" is a claim that quietly stops being true as tests are added.

test/test_admin_radio/test_main.cpp is the worked example:

void setUp(void) {
    // ...
    replaceAdminRadioGlobals();   // saves the globals, installs a fresh NodeDB
}
void tearDown(void) {
    restoreAdminRadioGlobals();   // restores them, deletes the NodeDB, re-runs initRegion()
    // ...
}

A fresh NodeDB per test costs real time (loadFromDisk() plus, when the region is set, key generation) - in that suite roughly 7% of a ~7½-minute run. Pay it. If a test genuinely needs to observe the previous test's state, that is what state=per-suite in test/state-manifest.tsv is for; say so there rather than achieving it by omission.

3. File-Scope Mutable Globals Persist Across Tests

Variables like static uint8_t someDenominator = 8; in the module .cpp file retain mutations from previous tests. This is distinct from member variables - it affects all instances.

Fix: Add a static void resetGlobal() method to the module and call it in setUp().

4. Randomness Breaks Determinism

If the module uses rand() for jitter or similar, test results become non-reproducible.

Fix: Add a static enable/disable flag:

// Module header:
static void setJitter(bool enabled) { s_jitterEnabled = enabled; }

// Test setUp:
YourModule::setJitter(false);

// Test tearDown:
YourModule::setJitter(true);

5. Time-Dependent Logic Produces Zeros

Rolling averages weighted by elapsedMs / ONE_HOUR_MS collapse to zero when tests complete in microseconds. Sample windows, EMA alphas, and interval-based accumulators all suffer from this.

Fix: Expose the timestamp via friend access and simulate realistic elapsed time:

// In test shim:
void setWindowStartMs(uint32_t ms) { windowStartMs = ms; }

// In test:
shim.setWindowStartMs(millis() - 3600000UL);  // pretend 1 hour elapsed

6. Capacity Limits Cause Cascading Failures

Fixed-size data structures (hash sets, ring buffers) overflow when tests inject more data than fits. This triggers early flushes with near-zero time fractions, compounding the time-dependent-zeros problem.

Fix: Simulate multiple realistic time windows rather than one massive burst. Let adaptive mechanisms (if any) self-tune over several rolls.

7. Granting test access to private/protected members

PlatformIO defines PIO_UNIT_TESTING during pio test builds. Several production headers (TransmitHistory.h, CryptoEngine.h, MQTT.h, RTC.h) use this to gate test-only visibility changes. PlatformIO also defines UNIT_TEST in the same builds for backward compatibility, but that spelling is deprecated - always use PIO_UNIT_TESTING in new code. The established pattern for exposing a private method to a test shim without widening production visibility:

#ifdef PIO_UNIT_TESTING
  protected:
#else
  private:
#endif
    bool myMethod();

Critical C++ rule: a using declaration in a derived class (e.g. using Base::myMethod) requires myMethod to be protected or public in the base - friend alone does not satisfy this. Adding friend class TestShim while leaving the method private will still fail to compile. Use the conditional access-specifier pattern above, not friend.

setUp/tearDown Checklist

  • Create and clear MockNodeDB (if needed)
  • Zero global configs: config, moduleConfig, myNodeInfo
  • Set nodeDB = mockNodeDB
  • Delete your own persisted state files (FSCom.remove(...)) for intra-suite isolation - cross-suite isolation is already guaranteed, see Pitfall 1
  • Declare deliberate writes to shared state in test/state-manifest.tsv, with a reason
  • Reset file-scope mutable globals
  • Reset mock clock to a safe base value (e.g. mockTime = ONE_HOUR_MS) - prevents unsigned subtraction underflow in time-dependent logic
  • Disable randomness/jitter flags
  • In tearDown: null the global singleton pointer, restore flags

Test Organization

A well-structured test suite follows this pattern:

  1. Topology/scenario builders - static helper functions that set up specific test conditions
  2. Injection helpers - simulate realistic traffic, time, or event patterns
  3. Scenario tests - each builds a scenario, runs the module, asserts on outcomes
  4. Lifecycle tests - state persistence, startup from blank, restart recovery
  5. Summary test (optional) - emits a scenario table into the log for quick CI review

Not a Unity suite: bin/test-config-check.sh

Portduino YAML validation is tested by driving a built meshtasticd rather than by a Unity suite, because what it asserts - the exit status and printed report of meshtasticd --check, and the fact that a normal run still refuses a bad config - are properties of the process, not of a linkable function. Fixtures live in test/fixtures/portduino-config/ (see the README there); CI runs it in test_native.yml. It is not counted in native-suite-count, which only tracks test_* directories.

pio run -e native && ./bin/test-config-check.sh

Existing Test Suites

This table is a description, not an inventory. The canonical suite total lives in test/native-suite-count, is machine-checked against test/test_* on every full run and in CI (test_native.yml), and is the only number that should be trusted or quoted. Entries below carry per-suite descriptions the count cannot; do not infer completeness from the row count.

Suite Module Under Test
test_admin_radio Admin + LoRa region config
test_fscommon_getfiles Bounded file-manifest walk
test_atak ATAK integration
test_crypto CryptoEngine
test_default Default configuration helpers
test_hop_scaling Hop scaling algorithm
test_http_content_handler HTTP handling
test_mac_from_string MAC address parsing
test_mesh_module Module framework
test_meshpacket_serializer Packet serialization
test_mqtt MQTT integration
test_packet_history Packet history tracking
test_position_precision Position precision helpers
test_radio Radio interface
test_serial Serial communication
test_module_config AdminModule module config
test_tak_config TAK (ATAK) team/role values
test_traffic_management Traffic management
test_transmit_history Retransmission tracking
test_type_conversions NodeDB v25 type conversions
test_utf8 UTF-8 utilities