mirror of
https://github.com/meshtastic/firmware
synced 2026-08-07 20:32:18 -04:00
Test suite rebuild (#11322)
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
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
* 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.
This commit is contained in:
parent
7d54c13ec0
commit
de6b23190a
40 changed files with 2348 additions and 173 deletions
58
.github/copilot-instructions.md
vendored
58
.github/copilot-instructions.md
vendored
|
|
@ -218,7 +218,8 @@ On every arch except STM32WL and bare nRF52832 (`WARM_NODE_COUNT > 0`), a node e
|
|||
- **Write:** `getOrCreateMeshNode`'s eviction and `demoteOldestHotNodesToWarm` (the over-cap boot migration) call `warmStore.absorb(num, last_heard, key)` _before_ the node leaves the header.
|
||||
- **Read-back:** `getOrCreateMeshNode` calls `warmStore.take()` to rehydrate `last_heard` + key when a warm node is re-admitted; `copyPublicKey()` falls back to the warm tier so the PKI send path finds keys for evicted peers.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring at `0xEA000` (below LittleFS; append + replay + compact-on-rotate, link-guarded by `nrf52840_s140_v7.ld` and `extra_scripts/nrf52_warm_region.py`). Everywhere else: a `/prefs/warm.dat` snapshot flushed by `saveIfDirty()` on the node-DB save cadence.
|
||||
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap - 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 keeps its flash-scaled 100/200/250, portduino 250). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
|
||||
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap - 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 picks 100/200/250 at boot from its flash size). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
|
||||
- **`MAX_NUM_NODES` on native is not in that header and is not a constant.** `variants/native/portduino{,-buildroot}/variant.h` define it as `portduino_config.MaxNodes` - resolved at **runtime**, default **200**, overridable per-host with `General: MaxNodes` in the portduino YAML. `variant.h` is reached first, so the `ARCH_PORTDUINO` branch in `mesh-pb-constants.h` never fires; it is now `#error`-guarded rather than holding a plausible-looking `250`. Reading 250 there yields a protected-node cap of 248 when the real one is 198 (`numProtectedNodes() < MAX_NUM_NODES - 2`), which has already produced one wrong diagnosis. The separate 250 in `NodeDB::getMaxNodesAllocatedSize()` is `NODEDB_MIGRATION_LOAD_CEILING`, a decode allowance for files from larger-cap firmware - not a cap.
|
||||
|
||||
### Satellite caps
|
||||
|
||||
|
|
@ -312,7 +313,7 @@ firmware/
|
|||
│ └── native/ # Linux/Portduino variants
|
||||
├── protobufs/ # Protocol buffer definitions
|
||||
├── boards/ # Custom PlatformIO board definitions
|
||||
├── test/ # Unit tests (12 test suites)
|
||||
├── test/ # Native unit-test suites (count: test/native-suite-count)
|
||||
└── bin/ # Build and utility scripts
|
||||
```
|
||||
|
||||
|
|
@ -662,9 +663,10 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
|
|||
|
||||
### Native unit tests (C++)
|
||||
|
||||
Unit tests in `test/` directory. The canonical suite count is in `test/native-suite-count` and is cross-checked on every full run. Current suites:
|
||||
Unit tests in `test/` directory. The canonical suite count is in `test/native-suite-count`, cross-checked against `test/test_*` on every full run and by the `suite-count-check` CI job. **Never state the count as a literal anywhere else** - point at that file. The list below is a partial description of what suites cover, not an inventory:
|
||||
|
||||
- `test_admin_radio/` - LoRa region/config validation and AdminModule dispatch
|
||||
- `test_admin_radio/` - LoRa region/config validation, AdminModule dispatch, node-DB metadata saves
|
||||
- `test_fscommon_getfiles/` - bounded file-manifest walk (cap, depth, truncation reporting)
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_default/` - Default configuration
|
||||
|
|
@ -691,21 +693,31 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su
|
|||
- `test_utf8/` - UTF-8 utilities
|
||||
- `test_warm_store/` - Warm-tier node store
|
||||
|
||||
**Preferred run command - `bin/run-tests.sh`** (uses the `coverage` env with ASan/LSan sanitizers; emits a machine-readable verdict on the final line; update `test/native-suite-count` when adding or removing suites):
|
||||
**Preferred run command - `bin/run-tests.sh`** (defaults to the `coverage` env; emits a machine-readable verdict on the final line; update `test/native-suite-count` when adding or removing suites):
|
||||
|
||||
```bash
|
||||
./bin/run-tests.sh # all suites
|
||||
./bin/run-tests.sh -f test_traffic_management # single suite (yields FILTERED, not GREEN)
|
||||
```
|
||||
|
||||
**The harness is Linux-only, and rejects anything else.** `bin/run-tests.sh` needs bash 4+ and GNU coreutils/find (`find -printf`, `md5sum`, `-executable`), so it exits 2 on a non-Linux `uname` rather than degrade quietly - a 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. On macOS or Windows use `./bin/test-native-docker.sh`.
|
||||
|
||||
**Sanitizer coverage is per env, and only one env has any.** `coverage` (the default) adds gcov + ASan/LSan on top of `native`. **`native` itself has none** - verified, zero ASan symbols in the built binary. A `-e native` run is _not_ sanitized, so do not reason from "run-tests.sh uses ASan" when you passed `-e native`.
|
||||
|
||||
**A signal name from the runner is not a crash.** `exit(UNITY_END())` returns the failure count, and PlatformIO's native runner renders a non-zero exit code as a POSIX signal - 4 failures prints `Program received signal SIGILL`, 5 prints `SIGTRAP`, and the suite is reported `[ERRORED]` instead of `[FAILED]`. Check the exit code against the failure count before theorising about memory bugs; confirm any real crash under a debugger.
|
||||
|
||||
**Suite order is randomisable.** `./bin/run-tests.sh --shuffle` runs suites in a seeded random order; `--seed <n>` replays one. The seed defaults to the commit SHA (deterministic per commit, varied across commits), is printed at the start and on the `RESULT:` line, and the full order is printed on failure. CI shuffles its area order the same way, seeded from `GITHUB_SHA`. A single green seed is not evidence of order independence.
|
||||
|
||||
**`-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.
|
||||
|
||||
Exit codes and verdicts (exact counts will vary; examples below are illustrative):
|
||||
|
||||
| Exit | Verdict | Meaning |
|
||||
| ---- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases |
|
||||
| 1 | `RED` | At least one failure, build error, or sanitizer fault |
|
||||
| 2 | `AMBER` | All that ran passed, but something was lost: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), or `test/native-suite-count` disagrees with the `test/` directory count |
|
||||
| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run |
|
||||
| Exit | Verdict | Meaning |
|
||||
| ---- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases |
|
||||
| 1 | `RED` | At least one failure, build error, or sanitizer fault |
|
||||
| 2 | `AMBER` | All that ran passed, but something was lost or unexplained: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), `test/native-suite-count` disagrees with the `test/` directory count, or a suite left behind shared state it does not declare |
|
||||
| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run |
|
||||
|
||||
Examples - exact counts will vary by suite count and env:
|
||||
|
||||
|
|
@ -745,6 +757,30 @@ Simulation testing: `bin/test-simulator.sh`
|
|||
|
||||
Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist).
|
||||
|
||||
### Shared state: every suite gets a clean sandbox
|
||||
|
||||
Each suite runs inside its own scratch `$HOME` (`bin/pio-test-isolate.sh`, wired in per env as `test_testing_command`, so a bare `pio test` and CI get it too). **State never crosses a suite boundary.** Mutation _inside_ a suite is free; carrying state _out_ of one is impossible by construction, not by policy.
|
||||
|
||||
The state in question lives in `~/.portduino/default/prefs/` - `nodes.proto`, `config.proto`, `channels.proto`, `module.proto`, `device.proto`, `warm.dat`, `transmit_history.dat`. `NodeDB`'s constructor calls `loadFromDisk()`, so any suite that constructs one reads it, and several `NodeDB` paths (`removeNodeByNum()`, `resetNodes()`, `nodeDBSelfCare()`, and the constructor when the file is absent) write it without being asked.
|
||||
|
||||
Two orthogonal axes: **PASS/FAIL x CLEAN/DIRTY**.
|
||||
|
||||
- **CLEAN** - nothing changed, or everything that changed is declared.
|
||||
- **DIRTY** - an undeclared path changed. Graded **AMBER**: with isolation in place it means "undeclared", not "dangerous".
|
||||
- **MISSING** - a declared write did not happen. A warning only; it catches persistence that silently stopped working.
|
||||
|
||||
Declare deliberate writes in **`test/state-manifest.tsv`** - one central file, `<suite>` / `<flags>` / `<reason>`, with the reason mandatory and reviewed on change. Central so every opt-out is visible in one diffable list; per-suite files hide growth. `run-tests.sh` prints how many suites declare non-default handling on every run.
|
||||
|
||||
| Flag | Meaning |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| _(no entry)_ | the default: fresh state in, contents discarded out |
|
||||
| `writes=<a,b>` | files this suite mutates on purpose; matched on the path relative to the sandbox `$HOME` or just the basename |
|
||||
| `state=per-suite` | state persists across this suite's own test cases (persistence round-trips, migration ladders). Only the suite boundary is checked; the default is per-test, which names the exact test that dirtied things |
|
||||
|
||||
No flag grants cross-suite carry. A suite that needs another suite's output needs an explicit fixture, not inheritance.
|
||||
|
||||
`./bin/run-tests.sh --write-manifest` prints the entries a run would need, for a human to paste and justify - it never applies them, and neither does CI. `bin/test-state-check.sh` is the checker's own self-test: fixtures asserting CLEAN / CLEAN / DIRTY / MISSING, plus the before-empty assertion.
|
||||
|
||||
### Hardware-in-the-loop tests ([meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp))
|
||||
|
||||
Separate pytest suite that exercises real USB-connected Meshtastic devices. It now lives in the standalone [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) repo, run against a firmware checkout via `MESHTASTIC_FIRMWARE_ROOT`. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules.
|
||||
|
|
|
|||
15
.github/node-id-format-allowlist.txt
vendored
Normal file
15
.github/node-id-format-allowlist.txt
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Exception list for bin/lint-node-id-format.sh (trunk linter: node-id-format).
|
||||
#
|
||||
# Format: <path>[:<line>] <reason>
|
||||
# - "<path>" exempts the whole file
|
||||
# - "<path>:<line>" exempts one call site
|
||||
# - blank lines and # comments are ignored; the reason column is mandatory
|
||||
#
|
||||
# Intentionally empty. The known pre-existing sites - PacketHistory.cpp, NodeInfoModule.cpp
|
||||
# and PositionModule.cpp - are deliberately NOT listed, so trunk surfaces them the next time
|
||||
# someone edits those files and they get cleaned up in the change that was already touching
|
||||
# them. The rule emits "note", trunk's only non-blocking level, so this costs a notice rather
|
||||
# than a red PR.
|
||||
#
|
||||
# Add an entry only for a value the linter has misread as an ID - a CRC, a register, a hash -
|
||||
# and say which, so the next reader can tell a real exemption from a deferred cleanup.
|
||||
50
.github/workflows/test_native.yml
vendored
50
.github/workflows/test_native.yml
vendored
|
|
@ -2,6 +2,16 @@ name: Run Tests on Native platform
|
|||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
suite_order_seed:
|
||||
description: >-
|
||||
Seed for shuffling the test-area order. Empty (the default) means: fixed declared order on
|
||||
pull_request, so a contributor's PR never turns red because of an order they did not
|
||||
choose; commit-SHA-derived elsewhere. Set a number to force that exact order anywhere -
|
||||
that is how you replay a shuffled failure.
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
|
@ -201,6 +211,11 @@ jobs:
|
|||
|
||||
- name: Run tests one area at a time
|
||||
shell: bash
|
||||
# Both values reach the script through env: rather than ${{ }} inside run:, so nothing from
|
||||
# the event payload is ever spliced into the shell text.
|
||||
env:
|
||||
SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# One runner, no matrix, no concurrency. Group the test_* suites by area and run each
|
||||
|
|
@ -236,6 +251,41 @@ jobs:
|
|||
for rule in "${area_rules[@]}"; do run_order+=("${rule%%:*}"); done
|
||||
run_order+=("misc")
|
||||
|
||||
# Area order. The rule order above is an accident of how the areas were written, and
|
||||
# running it fixed forever means order dependence between areas is never observed - but
|
||||
# randomising it on a contributor's PR would turn their run red for an order they did not
|
||||
# choose, which is how a randomisation gets reverted instead of the coupling fixed.
|
||||
#
|
||||
# So: pull_request keeps the fixed declared order. Everywhere else (push, schedule) the
|
||||
# order is shuffled, seeded from the commit SHA - deterministic per commit, replayable,
|
||||
# attributable, and it never blocks someone else's PR. An explicit seed input overrides
|
||||
# both, which is how you replay a specific failing order anywhere.
|
||||
#
|
||||
# Intra-area order stays PlatformIO's: filters select suites, they do not order them
|
||||
# (list_test_names() walks test/ with os.walk()), so controlling it needs one invocation
|
||||
# per suite. bin/run-tests.sh --shuffle does exactly that locally.
|
||||
seed_input="${SUITE_ORDER_SEED:-}"
|
||||
if [ -n "$seed_input" ]; then
|
||||
seed="$seed_input"
|
||||
echo "area order: shuffled with explicitly supplied seed $seed"
|
||||
elif [ "${EVENT_NAME:-}" = "pull_request" ]; then
|
||||
seed=""
|
||||
echo "area order: fixed declared order (pull_request) - ${run_order[*]}"
|
||||
echo " to exercise a different order, re-run this workflow with a suite_order_seed input"
|
||||
else
|
||||
seed=$((16#${GITHUB_SHA:0:8}))
|
||||
echo "area order: shuffled with seed $seed (from ${GITHUB_SHA:0:8})"
|
||||
fi
|
||||
|
||||
if [ -n "$seed" ]; then
|
||||
# Same shuffle_suites() bin/run-tests.sh uses, so the replay hint below is true by
|
||||
# construction rather than by two copies happening to agree.
|
||||
source bin/lib/shuffle.sh
|
||||
mapfile -t run_order < <(shuffle_suites "$seed" "${run_order[@]}")
|
||||
echo "area order: ${run_order[*]}"
|
||||
echo " replay locally: ./bin/run-tests.sh --shuffle --seed $seed"
|
||||
fi
|
||||
|
||||
fail=0
|
||||
for a in "${run_order[@]}"; do
|
||||
[ -n "${group[$a]:-}" ] || continue
|
||||
|
|
|
|||
|
|
@ -66,9 +66,37 @@ lint:
|
|||
run: ${workspace}/bin/lint-ifdef-complexity.sh ${target}
|
||||
success_codes: [0]
|
||||
read_output_from: stdout
|
||||
# Flags node/packet IDs logged as bare %08x instead of the 0x%08x convention in
|
||||
# src/mesh/RadioInterface.cpp. Emits "note", trunk's only non-blocking level, so it
|
||||
# advises without gating - including on the known pre-existing sites, which are left
|
||||
# unlisted so they get cleaned up by whoever next edits those files.
|
||||
- name: node-id-format
|
||||
files: [cpp-sources]
|
||||
commands:
|
||||
- name: lint
|
||||
output: regex
|
||||
parse_regex: (?P<path>.+):(?P<line>\d+):(?P<col>\d+):(?P<severity>\w+):(?P<message>.+):(?P<code>[a-z-]+)
|
||||
run: ${workspace}/bin/lint-node-id-format.sh ${target}
|
||||
success_codes: [0]
|
||||
read_output_from: stdout
|
||||
# Flags a UNITY_END() not wrapped in exit(). A bare one ends the reporting, not the suite:
|
||||
# the runtime keeps calling loop(), so the process never exits, its sandbox is deleted
|
||||
# underneath it, and its .gcda and LeakSanitizer report never flush. Emits "note" because the
|
||||
# enforcing half is bin/pio-test-isolate.sh, which catches an actual survivor at run time.
|
||||
- name: unity-exit
|
||||
files: [cpp-sources]
|
||||
commands:
|
||||
- name: lint
|
||||
output: regex
|
||||
parse_regex: (?P<path>.+):(?P<line>\d+):(?P<col>\d+):(?P<severity>\w+):(?P<message>.+):(?P<code>[a-z-]+)
|
||||
run: ${workspace}/bin/lint-unity-exit.sh ${target}
|
||||
success_codes: [0]
|
||||
read_output_from: stdout
|
||||
enabled:
|
||||
- ascii-dash@SYSTEM
|
||||
- too-many-defined@SYSTEM
|
||||
- node-id-format@SYSTEM
|
||||
- unity-exit@SYSTEM
|
||||
- checkov@3.3.8
|
||||
- renovate@44.2.3
|
||||
- prettier@3.9.6
|
||||
|
|
|
|||
13
AGENTS.md
13
AGENTS.md
|
|
@ -107,7 +107,16 @@ Sequence these; don't parallelize on the same port.
|
|||
4. On failure, open the run's `tests/report.html` → `Meshtastic debug` section for the firmware log tail + device state dump
|
||||
5. Iterate
|
||||
|
||||
### Debugging a flaky test
|
||||
### Debugging a native unit-test failure
|
||||
|
||||
1. **Run the full suite before believing a filtered one.** `-f` is not a gate: it removes the suites that _create_ the shared state a later suite trips over.
|
||||
2. **A signal name is not a crash.** `exit(UNITY_END())` returns the failure count and PlatformIO renders it as a signal (4 -> `SIGILL`, 5 -> `SIGTRAP`), reporting `[ERRORED]`. Match it against the failure count first.
|
||||
3. **Check the CLEAN/DIRTY axis.** Each suite runs in its own scratch `$HOME`; deliberate writes are declared in `test/state-manifest.tsv`. A DIRTY verdict names the suite and the undeclared path, and the kept sandbox under `.pio/test-state/<suite>/` is a replayable reproduction.
|
||||
4. **Sanitizers are per env** - `coverage` has ASan/LSan, `native` has none. Don't reason from ASan on a `-e native` run.
|
||||
5. **Reproduce a shuffled order.** `--shuffle` prints its seed and puts it on the `RESULT:` line; `--seed <n>` replays that exact order. One green seed proves nothing about order independence.
|
||||
6. **Exit 2 with "Linux-only" is the host, not the tests.** The harness needs bash 4+ and GNU coreutils/find and rejects any other `uname` rather than degrade quietly. `native-macos` is a build target, not a test host; elsewhere use `./bin/test-native-docker.sh`.
|
||||
|
||||
### Debugging a flaky hardware test
|
||||
|
||||
1. `/repro <test-node-id> [count]` - re-runs the test N times, diffs firmware logs between passes and failures
|
||||
2. If the first attempt always fails and the rest pass, that's a state-leak pattern → suggest `--force-bake` or a clean device state, don't chase the first failure
|
||||
|
|
@ -122,7 +131,7 @@ Sequence these; don't parallelize on the same port.
|
|||
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
|
||||
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
|
||||
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
|
||||
| `test/` | Firmware unit tests (19 suites; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) |
|
||||
| `test/` | Firmware unit tests (count: `test/native-suite-count`; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) |
|
||||
| [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) | Standalone MCP server + tiered pytest hardware harness (`unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/`) - registered here via `.mcp.json` |
|
||||
| `.github/prompts/` | Copilot prompt bodies (firmware scaffolding: new module / sensor / variant) |
|
||||
| `.github/copilot-instructions.md` | **Primary agent instructions - read this** |
|
||||
|
|
|
|||
29
bin/lib/shuffle.sh
Normal file
29
bin/lib/shuffle.sh
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# shellcheck shell=bash
|
||||
#
|
||||
# The seeded shuffle shared by bin/run-tests.sh and .github/workflows/test_native.yml. Sourced by
|
||||
# both so there is exactly one implementation; nothing here executes on its own.
|
||||
#
|
||||
# This has to live in one place. The workflow prints "replay locally: ./bin/run-tests.sh --shuffle
|
||||
# --seed $seed" after a CI shuffle, and that instruction is only true while CI and the local script
|
||||
# produce the same permutation for a seed. Two copies of the algorithm cannot be relied on to stay
|
||||
# byte-identical, and the way they'd announce their drift is a replay that quietly reproduces a
|
||||
# different order than the one that failed.
|
||||
|
||||
# Deterministic 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.
|
||||
shuffle_suites() {
|
||||
local seed="$1"
|
||||
shift
|
||||
# Nothing in, nothing out. `printf '%s\n'` with no arguments still writes one empty line, and the
|
||||
# callers read this through mapfile - so an empty suite list would arrive as a suite named "".
|
||||
(($#)) || return 0
|
||||
printf '%s\n' "$@" | awk -v seed="$seed" '
|
||||
function rnd() { s = (s * 16807) % 2147483647; return s / 2147483647 }
|
||||
BEGIN { s = seed % 2147483647; if (s <= 0) s += 2147483646 }
|
||||
{ a[NR] = $0 }
|
||||
END {
|
||||
for (i = NR; i > 1; i--) { j = int(rnd() * i) + 1; t = a[i]; a[i] = a[j]; a[j] = t }
|
||||
for (i = 1; i <= NR; i++) print a[i]
|
||||
}'
|
||||
}
|
||||
169
bin/lib/test-state.sh
Normal file
169
bin/lib/test-state.sh
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# shellcheck shell=bash
|
||||
#
|
||||
# Shared helpers for the native test harness's shared-state check. Sourced by
|
||||
# bin/pio-test-isolate.sh (which enforces it per suite) and bin/test-state-check.sh (which proves
|
||||
# the checker itself still works). Nothing here executes on its own.
|
||||
#
|
||||
# Linux-only, like the rest of the native harness: this uses GNU coreutils behaviour (`find -printf`,
|
||||
# md5sum) rather than carrying a per-host fallback. bin/run-tests.sh states and enforces that.
|
||||
#
|
||||
# The check answers one question: did this suite change any file it did not declare? It deliberately
|
||||
# does NOT compare file *contents* against a baseline. Content baselines over protobuf bytes are
|
||||
# snapshot tests - add a field to NodeInfoLite and every recorded hash in the repo churns, which is
|
||||
# how snapshot suites turn into an --update-all ritual and then into noise. Hashes are used only to
|
||||
# answer the boolean "did this change?"; what gets declared and reviewed is the set of paths.
|
||||
|
||||
STATE_MANIFEST_DEFAULT="test/state-manifest.tsv"
|
||||
|
||||
# Files the native binaries persist under $HOME. Listed for documentation and for the
|
||||
# --write-manifest hint; the scan itself is unfiltered, so a suite writing somewhere unexpected is
|
||||
# still caught.
|
||||
# shellcheck disable=SC2034 # referenced by callers and by the docs
|
||||
STATE_KNOWN_FILES="nodes.proto config.proto channels.proto module.proto device.proto warm.dat transmit_history.dat"
|
||||
|
||||
# Guard the guard: refuse to run a suite against a sandbox that is not empty. If isolation ever
|
||||
# leaks, the after-diff measures against the wrong baseline and the whole check reports CLEAN while
|
||||
# meaning nothing - so before-empty is as load-bearing as after-diff. Returns non-zero and explains
|
||||
# itself rather than carrying on.
|
||||
state_assert_empty() {
|
||||
local dir="$1"
|
||||
if [[ -n $(find "$dir" -mindepth 1 -print -quit 2>/dev/null) ]]; then
|
||||
echo "test-state: sandbox $dir is not empty before the suite ran - isolation is broken" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fingerprint every file under $1 as "<relative-path> <md5>", sorted. Empty output for an empty or
|
||||
# missing tree. Output is fed to comm/diff, so the sort order has to be stable across calls.
|
||||
#
|
||||
# GNU `find -printf` and md5sum(1), deliberately: this harness is Linux-only and bin/run-tests.sh
|
||||
# refuses to start anywhere else, so there is no host here that needs a BSD fallback.
|
||||
state_fingerprint() {
|
||||
local root="$1"
|
||||
[[ -d $root ]] || return 0
|
||||
(
|
||||
cd "$root" || return 0
|
||||
find . -type f -printf '%P\n' 2>/dev/null | LC_ALL=C sort | while IFS= read -r rel; do
|
||||
printf '%s %s\n' "$rel" "$(md5sum -- "$rel" 2>/dev/null | cut -d' ' -f1)"
|
||||
done
|
||||
)
|
||||
}
|
||||
|
||||
# Processes still running inside the suite's sandbox $HOME. Prints one PID per line.
|
||||
#
|
||||
# A suite that ends on a bare UNITY_END() does not stop: setup() returns, the runtime keeps calling
|
||||
# loop(), and PlatformIO - which reports a suite from its Unity output, not from process exit -
|
||||
# moves on with the binary still resident. Nothing else notices, and the damage is quiet: the
|
||||
# sandbox gets deleted under a live process, so the after-fingerprint below describes what the suite
|
||||
# had written when we stopped looking rather than what it left behind, and .gcda plus LeakSanitizer
|
||||
# both flush from atexit handlers that never run.
|
||||
#
|
||||
# Matching on the environment rather than on a remembered PID is deliberate: the sandbox HOME is
|
||||
# mktemp-unique per suite, so this identifies survivors whatever their parentage - a fork, a
|
||||
# grandchild, a process already reparented to init - none of which a $! comparison would catch.
|
||||
# Scoped to this user's processes: /proc/<pid>/environ is unreadable for anyone else's anyway, and
|
||||
# the narrower sweep costs ~270ms against ~460ms for all of /proc.
|
||||
state_find_survivors() {
|
||||
local home="$1" pid
|
||||
[[ -n $home ]] || return 0
|
||||
for pid in $(ps -u "$(id -u)" -o pid= 2>/dev/null); do
|
||||
[[ $pid == "$$" ]] && continue
|
||||
if tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | grep -qxF "HOME=$home"; then
|
||||
printf '%s\n' "$pid"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Paths present in the "after" fingerprint ($2) that are absent or different in "before" ($1).
|
||||
# Prints one relative path per line.
|
||||
state_changed_paths() {
|
||||
local before="$1" after="$2"
|
||||
LC_ALL=C comm -13 <(LC_ALL=C sort "$before") <(LC_ALL=C sort "$after") | awk '{print $1}' | LC_ALL=C sort -u
|
||||
}
|
||||
|
||||
# Read a suite's flag string out of the manifest. Empty when the suite has no entry, which is the
|
||||
# default and means "isolated": fresh state in, contents discarded out.
|
||||
#
|
||||
# Manifest format - TSV, three columns, the same shape as an allowlist entry: the thing, what it is
|
||||
# allowed to do, and why. The reason column is mandatory and is what a reviewer reads.
|
||||
#
|
||||
# test_nodedb_blocked<TAB>state=per-suite writes=nodes.proto<TAB>saturates the DB to test the cap
|
||||
state_manifest_flags() {
|
||||
local suite="$1" manifest="${2:-$STATE_MANIFEST_DEFAULT}"
|
||||
[[ -f $manifest ]] || return 0
|
||||
awk -F'\t' -v s="$suite" '!/^[[:space:]]*#/ && $1 == s { print $2; exit }' "$manifest"
|
||||
}
|
||||
|
||||
# Pull one flag's value out of a flag string: state_flag_value "writes" "state=per-suite writes=a,b"
|
||||
state_flag_value() {
|
||||
local key="$1" flags="$2" f
|
||||
for f in $flags; do
|
||||
[[ $f == "$key="* ]] && {
|
||||
printf '%s' "${f#"$key="}"
|
||||
return 0
|
||||
}
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Does a changed path match a declared write? A declaration matches either the full path relative to
|
||||
# the scratch HOME or just the basename, because the useful name for these is the basename
|
||||
# (`nodes.proto`) and nobody should have to write .portduino/default/prefs/ in front of it.
|
||||
state_path_declared() {
|
||||
local path="$1" declared="$2" entry
|
||||
IFS=',' read -ra _entries <<<"$declared"
|
||||
for entry in "${_entries[@]}"; do
|
||||
[[ -z $entry ]] && continue
|
||||
[[ $path == "$entry" || ${path##*/} == "$entry" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Classify a suite's leftovers. Prints "<verdict>\t<detail>" where verdict is one of:
|
||||
#
|
||||
# CLEAN nothing changed, or everything that changed was declared
|
||||
# DIRTY at least one undeclared path changed - the finding this whole check exists for
|
||||
# MISSING every changed path was declared, but a declared path did NOT change
|
||||
#
|
||||
# MISSING is reported separately rather than folded into DIRTY because it catches the opposite bug:
|
||||
# persistence that silently stopped happening. That is a real class here - the TAK config bug
|
||||
# upstream was a has_ flag never being set, so the save wrote nothing and no test noticed. It starts
|
||||
# as a warning because some declared writes are legitimately conditional.
|
||||
state_classify() {
|
||||
local changed="$1" declared="$2"
|
||||
local undeclared=() missing=() path entry found
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z $path ]] && continue
|
||||
if ! state_path_declared "$path" "$declared"; then
|
||||
undeclared+=("$path")
|
||||
fi
|
||||
done <<<"$changed"
|
||||
|
||||
# Ask state_path_declared() in the other direction rather than matching by hand: one rule for
|
||||
# "does this path match this declaration", so the two directions cannot drift apart. Matching
|
||||
# an entry as a regex would also let a metacharacter in a manifest name (`.`, `+`) match a file
|
||||
# that is not the declared one.
|
||||
IFS=',' read -ra _declared <<<"$declared"
|
||||
for entry in "${_declared[@]}"; do
|
||||
[[ -z $entry ]] && continue
|
||||
found=1
|
||||
while IFS= read -r path; do
|
||||
[[ -z $path ]] && continue
|
||||
if state_path_declared "$path" "$entry"; then
|
||||
found=0
|
||||
break
|
||||
fi
|
||||
done <<<"$changed"
|
||||
((found)) && missing+=("$entry")
|
||||
done
|
||||
|
||||
if ((${#undeclared[@]} > 0)); then
|
||||
printf 'DIRTY\tundeclared: %s\n' "${undeclared[*]}"
|
||||
elif ((${#missing[@]} > 0)); then
|
||||
printf 'MISSING\tdeclared but unwritten: %s\n' "${missing[*]}"
|
||||
else
|
||||
printf 'CLEAN\t\n'
|
||||
fi
|
||||
}
|
||||
102
bin/lint-node-id-format.sh
Executable file
102
bin/lint-node-id-format.sh
Executable file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env bash
|
||||
# lint-node-id-format.sh - flag node/packet IDs logged as bare %08x instead of 0x%08x.
|
||||
#
|
||||
# src/mesh/RadioInterface.cpp states the convention: node IDs and packet IDs are
|
||||
# formatted 0x%08x in logs and !%08x in user-facing display. A bare %08x still
|
||||
# prints the right digits, so nothing breaks - it just makes the value hard to
|
||||
# grep for and easy to misread as decimal.
|
||||
#
|
||||
# Emitted at "note" on purpose: this is a consistency rule, not a correctness one, so
|
||||
# it should never be the thing that fails someone's review. Note is trunk's only
|
||||
# non-blocking level - "warning" and "info" both exit non-zero and would gate CI.
|
||||
#
|
||||
# Only flags a %08x whose statement also mentions an ID-shaped argument
|
||||
# (->num, .from, nodeNum, getFrom(), ...). A 32-bit hex value that is not an ID -
|
||||
# a CRC, a register, a hash - is none of this rule's business.
|
||||
#
|
||||
# Emits one line per finding in the format
|
||||
# <path>:<line>:<col>:<severity>:<message>:<code>
|
||||
# which trunk parses via parse_regex. Always exits 0; findings go to stdout.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ALLOWLIST=".github/node-id-format-allowlist.txt"
|
||||
|
||||
for target in "$@"; do
|
||||
[[ -f $target ]] || continue
|
||||
|
||||
# Path is reported relative to the workspace so allowlist entries stay portable.
|
||||
rel="${target#"$PWD"/}"
|
||||
|
||||
awk -v path="$rel" -v allowlist="$ALLOWLIST" '
|
||||
BEGIN {
|
||||
LINE_CAP = 12 # give up accumulating a statement after this many lines
|
||||
|
||||
# Allowlist entries are "<path>" (whole file) or "<path>:<line>", followed by
|
||||
# whitespace and a mandatory reason. Blank lines and # comments are ignored.
|
||||
while ((getline line < allowlist) > 0) {
|
||||
sub(/#.*/, "", line)
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", line)
|
||||
if (line == "") continue
|
||||
split(line, f, /[ \t]+/)
|
||||
skip[f[1]] = 1
|
||||
}
|
||||
close(allowlist)
|
||||
if (path in skip) exempt_file = 1
|
||||
}
|
||||
|
||||
# Two independent signals, either of which marks the value as an ID: an ID-shaped
|
||||
# argument, or the message text naming one. The second catches the common
|
||||
# LOG_INFO("node %08x", n) shape, where the argument alone is indistinguishable
|
||||
# from a CRC. Deliberately a allowlist of shapes rather than "any variable" - a
|
||||
# false positive here costs more than a miss.
|
||||
function looks_like_id(s) {
|
||||
return (s ~ /(->|\.)(num|from|to|id|dest|sender|relay_node|next_hop)[^A-Za-z0-9_]/) ||
|
||||
(s ~ /[Nn]ode[Nn]um/) || (s ~ /nodeId/) || (s ~ /getFrom[ \t]*\(/) ||
|
||||
(s ~ /[^A-Za-z0-9_]sender[^A-Za-z0-9_]/) ||
|
||||
(s ~ /[Nn]ode/) || (s ~ /[Pp]acket/) || (s ~ /[Ss]ender/) || (s ~ /[Rr]elay/)
|
||||
}
|
||||
|
||||
# True when the text still contains a %08x after every correctly-prefixed
|
||||
# 0x%08x has been removed - i.e. at least one occurrence is bare.
|
||||
function has_bare_hex(s, t) {
|
||||
t = s
|
||||
gsub(/0[xX]%08[xX]/, "", t)
|
||||
gsub(/![ \t]*%08[xX]/, "", t) # !%08x is the user-facing display form, also fine
|
||||
return (t ~ /%08[xX]/)
|
||||
}
|
||||
|
||||
{
|
||||
if (exempt_file) next
|
||||
|
||||
# Accumulate a logical LOG_ statement; these routinely wrap across lines.
|
||||
if (!in_stmt && $0 ~ /LOG_[A-Z]+[ \t]*\(/) {
|
||||
in_stmt = 1; stmt = $0; start = NR; hit_line = 0; hit_col = 0
|
||||
} else if (in_stmt) {
|
||||
stmt = stmt " " $0
|
||||
} else {
|
||||
next
|
||||
}
|
||||
|
||||
# Remember the first line carrying a bare %08x, for a useful caret position.
|
||||
if (!hit_line && has_bare_hex($0)) { hit_line = NR; hit_col = index($0, "%08") }
|
||||
|
||||
# End of statement: any line closing the call. Matched anywhere on the line, not
|
||||
# just at EOL, so `LOG_INFO(...); }` terminates too - if it did not, the
|
||||
# accumulator would run to EOF and silently swallow every later finding in the
|
||||
# file. LINE_CAP is the same backstop for a close paren we never see at all.
|
||||
if ($0 ~ /\)[ \t]*;/ || NR - start >= LINE_CAP) {
|
||||
if (has_bare_hex(stmt) && looks_like_id(stmt)) {
|
||||
if (!hit_line) { hit_line = start; hit_col = 1 }
|
||||
if ((path ":" hit_line) in skip) { in_stmt = 0; next }
|
||||
printf "%s:%d:%d:%s:%s:%s\n", path, hit_line, (hit_col ? hit_col : 1), "note",
|
||||
"node/packet ID logged as bare %08x - use 0x%08x (see src/mesh/RadioInterface.cpp)",
|
||||
"node-id-format"
|
||||
}
|
||||
in_stmt = 0
|
||||
}
|
||||
}
|
||||
' "$target"
|
||||
done
|
||||
|
||||
exit 0
|
||||
166
bin/lint-unity-exit.sh
Executable file
166
bin/lint-unity-exit.sh
Executable file
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env bash
|
||||
# lint-unity-exit.sh - flag a UNITY_END() that is not wrapped in exit().
|
||||
#
|
||||
# A bare UNITY_END() ends the *reporting*, not the suite: 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 on - so the run is green while the binary
|
||||
# is still resident. The costs are invisible by construction: the per-suite sandbox is deleted
|
||||
# under a live process, and .gcda coverage plus LeakSanitizer's report are both flushed by atexit
|
||||
# handlers, so a suite that never exits contributes no coverage and gets no leak check.
|
||||
#
|
||||
# The rule is per occurrence, not per file. test/test_serial/SerialModule.cpp had a correct
|
||||
# exit(UNITY_END()) in its ESP32 branch and bare ones in both #else branches; a "does this file
|
||||
# call exit() anywhere" check passes it. The empty branch of a feature or architecture guard is
|
||||
# the easiest one to get wrong, because it looks like there is nothing to clean up.
|
||||
#
|
||||
# Statement-aware, like bin/lint-node-id-format.sh and for the same reason: judging one physical
|
||||
# line at a time reports `exit(\n UNITY_END());` as bare, and reports the interior lines of a
|
||||
# /* ... */ block comment that happens to mention the macro. A note-level rule that cries wolf
|
||||
# gets ignored, and then the real finding goes with it.
|
||||
#
|
||||
# bin/test-lint-unity-exit.sh is this rule's self-test. It exists because the scanner has now been
|
||||
# wrong twice: every false positive and false negative found in review is pinned there as a
|
||||
# fixture, so the next rewrite has to keep them all passing.
|
||||
#
|
||||
# Not handled: raw string literals (R"(...)"). There are none under test/, and delimiter tracking
|
||||
# for a case that does not occur would be untested code guarding untested code.
|
||||
#
|
||||
# Emitted at "note" - trunk's only non-blocking level - because the enforcing half of this pair is
|
||||
# bin/pio-test-isolate.sh, which detects an actual survivor at run time and grades it AMBER. This
|
||||
# is the author-time advice that stops it being written in the first place.
|
||||
#
|
||||
# Emits one line per finding in the format
|
||||
# <path>:<line>:<col>:<severity>:<message>:<code>
|
||||
# which trunk parses via parse_regex. Always exits 0; findings go to stdout.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
for target in "$@"; do
|
||||
[[ -f $target ]] || continue
|
||||
|
||||
# Path is reported relative to the workspace so findings are clickable from the repo root.
|
||||
rel="${target#"$PWD"/}"
|
||||
|
||||
# Only test sources declare a suite's lifecycle. Unity's own headers and any production file
|
||||
# mentioning the macro are none of this rule's business.
|
||||
[[ $rel == test/* ]] || continue
|
||||
|
||||
awk -v path="$rel" '
|
||||
# Return the line with comments and string/char literals removed, carrying /* ... */ state
|
||||
# across lines. A character-level scan, not layered regexes: regexes cannot tokenise C++ and
|
||||
# each attempt was wrong differently - a /* inside a string literal flipped comment state and
|
||||
# hid real calls, a greedy .* swallowed the code between two comments on one line, and
|
||||
# UNITY_END() inside a string read as code. Literals collapse to a space rather than vanishing,
|
||||
# so a token cannot be glued to its neighbour.
|
||||
# Also fills colmap[], mapping each position in the returned string back to its column in the
|
||||
# raw line. Without it a caret cannot be placed: removing a comment or collapsing a literal
|
||||
# shifts every later column, and counting occurrences in the raw line does not help either -
|
||||
# TEST_MESSAGE("... UNITY_END() ..."); UNITY_END(); has two in the raw text and one in the code.
|
||||
function strip_noncode(s, out, i, n, c, two, q) {
|
||||
n = length(s); i = 1; out = ""
|
||||
delete colmap
|
||||
while (i <= n) {
|
||||
if (in_block) {
|
||||
if (substr(s, i, 2) == "*/") { in_block = 0; i += 2 } else { i++ }
|
||||
continue
|
||||
}
|
||||
two = substr(s, i, 2)
|
||||
if (two == "//") return out # rest of the line is a comment
|
||||
if (two == "/*") { in_block = 1; i += 2; continue }
|
||||
c = substr(s, i, 1)
|
||||
if (c == "\"" || c == "'"'"'") { # skip a whole literal, honouring backslash escapes
|
||||
q = c
|
||||
out = out " "; colmap[length(out)] = i
|
||||
i++
|
||||
while (i <= n) {
|
||||
c = substr(s, i, 1)
|
||||
if (c == "\\") { i += 2; continue }
|
||||
i++
|
||||
if (c == q) break
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = out c; colmap[length(out)] = i; i++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# Is this one occurrence wrapped in a form that terminates the process? Two count:
|
||||
#
|
||||
# exit(UNITY_END()) the documented one
|
||||
# int rc = UNITY_END() capture-then-exit, used by test_packet_signing to restore globals
|
||||
# between the summary and the exit
|
||||
#
|
||||
# Judged per occurrence by looking back through whitespace, not by stripping forms out of the
|
||||
# whole statement. A line carrying both - exit(UNITY_END()); UNITY_END(); - must report the bare
|
||||
# call at the bare column, rather than once at whichever came first.
|
||||
#
|
||||
# Both forms are token-bounded. `exit` must be a whole identifier, so myexit(UNITY_END()) is
|
||||
# still reported; the assignment must be a plain `=`, so `==`, `!=`, `<=`, `>=` and `+=` are not
|
||||
# mistaken for a capture. `return UNITY_END()` is deliberately NOT accepted - it only terminates
|
||||
# from main(), there is no main() under test/, and from a helper it just returns a count.
|
||||
#
|
||||
# Where the rule gives ground: capturing the value and then never exiting would leak and is not
|
||||
# flagged. That is rarer than the bare call, and flagging a correct idiom would push someone to
|
||||
# "fix" working code.
|
||||
function is_wrapped(s, at, j, c, tail) {
|
||||
j = at - 1
|
||||
while (j >= 1 && substr(s, j, 1) ~ /[ \t]/) j-- # skip space before the macro
|
||||
if (j < 1) return 0
|
||||
|
||||
# exit ( UNITY_END - `exit` must be a whole identifier, so myexit( does not qualify
|
||||
if (substr(s, j, 1) == "(") {
|
||||
j--
|
||||
while (j >= 1 && substr(s, j, 1) ~ /[ \t]/) j--
|
||||
if (j >= 4 && substr(s, j - 3, 4) == "exit" &&
|
||||
(j - 4 < 1 || substr(s, j - 4, 1) !~ /[A-Za-z0-9_]/)) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# = UNITY_END - a plain assignment is capture-then-exit; ==, !=, <=, >=, += are not
|
||||
if (substr(s, j, 1) == "=") {
|
||||
c = (j - 1 >= 1) ? substr(s, j - 1, 1) : " "
|
||||
tail = (j + 1 <= length(s)) ? substr(s, j + 1, 1) : " "
|
||||
if (c ~ /[-+*\/%&|^!<>=]/ || tail == "=") return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
BEGIN { LINE_CAP = 12 } # give up accumulating a statement after this many lines
|
||||
|
||||
{
|
||||
code = strip_noncode($0)
|
||||
|
||||
# Record every occurrence on this line with the position it has in the accumulated
|
||||
# statement, plus its real line and column, so each can be judged and reported separately.
|
||||
if (stmt == "") { start = NR; nhits = 0 }
|
||||
base = length(stmt) + 1 # the leading space added below shifts everything by one
|
||||
stmt = stmt " " code
|
||||
|
||||
off = 0
|
||||
while ((p = index(substr(code, off + 1), "UNITY_END")) > 0) {
|
||||
off += p
|
||||
nhits++
|
||||
hit_at[nhits] = base + off # index within stmt
|
||||
hit_line[nhits] = NR
|
||||
hit_col[nhits] = colmap[off]
|
||||
}
|
||||
|
||||
# End of statement. The cap is the backstop for a semicolon we never see, so one unclosed
|
||||
# call cannot swallow every later finding in the file.
|
||||
if (code ~ /;/ || NR - start >= LINE_CAP) {
|
||||
for (k = 1; k <= nhits; k++)
|
||||
if (!is_wrapped(stmt, hit_at[k]))
|
||||
printf "%s:%d:%d:%s:%s:%s\n", path, hit_line[k], hit_col[k], "note",
|
||||
"bare UNITY_END() leaves the process running - use exit(UNITY_END()) (see test/README.md)",
|
||||
"unity-exit"
|
||||
stmt = ""
|
||||
nhits = 0
|
||||
}
|
||||
}
|
||||
' "$target"
|
||||
done
|
||||
|
||||
exit 0
|
||||
133
bin/pio-test-isolate.sh
Executable file
133
bin/pio-test-isolate.sh
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env bash
|
||||
# PlatformIO `test_testing_command` wrapper - runs one native test suite in its own scratch $HOME
|
||||
# and reports what it left behind. Registered per-env in variants/native/portduino/platformio.ini,
|
||||
# so it applies to a bare `pio test` and to CI, not only to bin/run-tests.sh.
|
||||
#
|
||||
# Every native suite that constructs a NodeDB loads and saves ~/.portduino/default/prefs/, and
|
||||
# nothing cleared it between suites, so state leaked suite -> suite within a run and run -> run
|
||||
# after it. Per-*run* isolation is not enough: the leak is generated within a single run, so the
|
||||
# boundary has to be per suite.
|
||||
#
|
||||
# Contract: run "$@" unchanged, exit with its exit code. PlatformIO's own pass/fail is untouched -
|
||||
# everything else here is reporting.
|
||||
#
|
||||
# Escape hatch: MESHTASTIC_TEST_NO_ISOLATION=1 runs the binary bare, for when you need the real
|
||||
# $HOME (e.g. reproducing against a live prefs directory).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [[ ${MESHTASTIC_TEST_NO_ISOLATION:-0} == 1 ]]; then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
# shellcheck source=bin/lib/test-state.sh
|
||||
source "$SCRIPT_DIR/lib/test-state.sh"
|
||||
|
||||
STATE_ROOT="${MESHTASTIC_TEST_STATE_DIR:-$ROOT_DIR/.pio/test-state}"
|
||||
MANIFEST="${MESHTASTIC_TEST_STATE_MANIFEST:-$ROOT_DIR/$STATE_MANIFEST_DEFAULT}"
|
||||
SUMMARY="${MESHTASTIC_TEST_STATE_SUMMARY:-$STATE_ROOT/summary.tsv}"
|
||||
|
||||
if ! mkdir -p "$STATE_ROOT" 2>/dev/null; then
|
||||
echo "pio-test-isolate: cannot create $STATE_ROOT - running without isolation" >&2
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
SCRATCH="$(mktemp -d "$STATE_ROOT/suite.XXXXXX")" || exec "$@"
|
||||
SUITE_HOME="$SCRATCH/home"
|
||||
LOG="$SCRATCH/output.log"
|
||||
REPORT="$SCRATCH/per-test.tsv"
|
||||
mkdir -p "$SUITE_HOME"
|
||||
|
||||
state_assert_empty "$SUITE_HOME" || exit 1
|
||||
|
||||
BEFORE="$SCRATCH/before.fp"
|
||||
state_fingerprint "$SUITE_HOME" >"$BEFORE"
|
||||
|
||||
# HOME points at the sandbox; PLATFORMIO_CORE_DIR is pinned to the real one so nothing re-downloads
|
||||
# a toolchain into a directory we are about to delete. (Overriding HOME around `pio` itself is what
|
||||
# breaks its own ~/.platformio/penv/bin/pio lookup - doing it here, around the already-built binary,
|
||||
# sidesteps that entirely.)
|
||||
REAL_HOME="$HOME"
|
||||
HOME="$SUITE_HOME" \
|
||||
PLATFORMIO_CORE_DIR="${PLATFORMIO_CORE_DIR:-$REAL_HOME/.platformio}" \
|
||||
MESHTASTIC_TEST_STATE_REPORT="$REPORT" \
|
||||
"$@" 2>&1 | tee "$LOG"
|
||||
RC=${PIPESTATUS[0]}
|
||||
|
||||
# Survivors, before anything else looks at the sandbox: reap them first so the after-fingerprint is
|
||||
# taken against a tree nobody is still writing to, and so a run cannot leave processes accumulating
|
||||
# on the host. SIGTERM, then SIGKILL for anything that ignores it. Reported on the summary line as a
|
||||
# fourth outcome - it is not a filesystem verdict, and folding it into DIRTY would lose the reason.
|
||||
SURVIVORS="$(state_find_survivors "$SUITE_HOME" | tr '\n' ' ')"
|
||||
SURVIVORS="${SURVIVORS% }"
|
||||
if [[ -n $SURVIVORS ]]; then
|
||||
# shellcheck disable=SC2086 # deliberate word splitting: SURVIVORS is a PID list
|
||||
kill $SURVIVORS 2>/dev/null
|
||||
sleep 0.2
|
||||
STILL="$(state_find_survivors "$SUITE_HOME" | tr '\n' ' ')"
|
||||
# shellcheck disable=SC2086 # as above
|
||||
[[ -n ${STILL// /} ]] && kill -9 $STILL 2>/dev/null
|
||||
echo "pio-test-isolate: survivor(s) still running after the suite finished: $SURVIVORS (killed)" >&2
|
||||
fi
|
||||
|
||||
# The suite name is not passed to a test_testing_command, so recover it from the output: every Unity
|
||||
# result line carries the suite's source path. Fall back to the per-test report, which records it
|
||||
# from __FILE__, and finally to the scratch dir name.
|
||||
SUITE="$(grep -oE 'test/test_[a-z0-9_]+/' "$LOG" 2>/dev/null | head -1 | sed -E 's#test/(test_[a-z0-9_]+)/#\1#')"
|
||||
if [[ -z $SUITE && -f $REPORT ]]; then
|
||||
SUITE="$(awk -F'\t' 'NR==1 {print $1}' "$REPORT")"
|
||||
fi
|
||||
[[ -z $SUITE ]] && SUITE="unknown-$(basename "$SCRATCH")"
|
||||
|
||||
AFTER="$SCRATCH/after.fp"
|
||||
state_fingerprint "$SUITE_HOME" >"$AFTER"
|
||||
CHANGED="$(state_changed_paths "$BEFORE" "$AFTER")"
|
||||
|
||||
FLAGS="$(state_manifest_flags "$SUITE" "$MANIFEST")"
|
||||
DECLARED="$(state_flag_value writes "$FLAGS")"
|
||||
GRANULARITY="$(state_flag_value state "$FLAGS")"
|
||||
[[ -z $GRANULARITY ]] && GRANULARITY="per-test"
|
||||
|
||||
IFS=$'\t' read -r VERDICT DETAIL <<<"$(state_classify "$CHANGED" "$DECLARED")"
|
||||
|
||||
# Per-test attribution, when the suite has not declared that it carries state across its own test
|
||||
# cases. For a state=per-suite suite every test after the first would be flagged by design - that
|
||||
# carry *is* the declared behaviour - so only the suite boundary is meaningful there.
|
||||
PER_TEST_DETAIL=""
|
||||
if [[ $GRANULARITY == "per-test" && -f $REPORT ]]; then
|
||||
awk -F'\t' -v d="$DECLARED" '
|
||||
BEGIN { n = split(d, a, ","); }
|
||||
{
|
||||
path = $4; base = path; sub(/^.*\//, "", base);
|
||||
for (i = 1; i <= n; i++) if (a[i] == path || a[i] == base) next;
|
||||
print $2 " -> " base;
|
||||
}' "$REPORT" | LC_ALL=C sort -u >"$SCRATCH/per-test-undeclared.txt"
|
||||
# Keep the summary line readable; the full attribution stays in the sandbox's per-test.tsv.
|
||||
PER_TEST_COUNT=$(wc -l <"$SCRATCH/per-test-undeclared.txt")
|
||||
# Not `paste -sd'; '`: with -s, paste cycles through a multi-char delimiter one character per
|
||||
# join, so five paths render as "a;b c;d e" rather than "a; b; c; d; e".
|
||||
PER_TEST_DETAIL="$(head -5 "$SCRATCH/per-test-undeclared.txt" | awk '{printf "%s%s", (NR > 1 ? "; " : ""), $0} END {print ""}')"
|
||||
((PER_TEST_COUNT > 5)) && PER_TEST_DETAIL="$PER_TEST_DETAIL; +$((PER_TEST_COUNT - 5)) more"
|
||||
fi
|
||||
|
||||
STATUS=$([[ $RC -eq 0 ]] && echo PASS || echo FAIL)
|
||||
mkdir -p "$(dirname "$SUMMARY")" 2>/dev/null
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$SUITE" "$STATUS" "$VERDICT" "${DETAIL-}" "${PER_TEST_DETAIL-}" \
|
||||
"${SURVIVORS-}" >>"$SUMMARY"
|
||||
|
||||
# Keep the sandbox when there is something to look at: on a failure it plus the built binary is a
|
||||
# complete, replayable reproduction, and on a DIRTY verdict the leftovers *are* the bug report. A
|
||||
# clean pass leaves nothing behind.
|
||||
KEEP="${MESHTASTIC_TEST_KEEP_STATE:-0}"
|
||||
if [[ $RC -ne 0 || $VERDICT != CLEAN || -n ${SURVIVORS-} || $KEEP == 1 ]]; then
|
||||
DEST="$STATE_ROOT/$SUITE"
|
||||
rm -rf "$DEST" 2>/dev/null
|
||||
mv "$SCRATCH" "$DEST" 2>/dev/null || DEST="$SCRATCH"
|
||||
echo "pio-test-isolate: $SUITE $STATUS/$VERDICT - state and log kept at $DEST" >&2
|
||||
else
|
||||
rm -rf "$SCRATCH"
|
||||
fi
|
||||
|
||||
exit "$RC"
|
||||
387
bin/run-tests.sh
387
bin/run-tests.sh
|
|
@ -2,7 +2,7 @@
|
|||
# Run native PlatformIO unit tests and emit a single, unambiguous verdict.
|
||||
#
|
||||
# Why this exists: PlatformIO reports failures three different ways ([FAILED], :FAIL:,
|
||||
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause — so naive
|
||||
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause - so naive
|
||||
# greps produce false greens (see .notes/test-passfail-filter.md). This script encodes the
|
||||
# correct logic once, and cross-checks the number of suites that actually ran against the
|
||||
# canonical set in test/ so a suite silently going missing shows up as AMBER, not green.
|
||||
|
|
@ -12,37 +12,85 @@
|
|||
# ./bin/run-tests.sh -f test_utf8 # run one suite (yields FILTERED, not GREEN)
|
||||
# ./bin/run-tests.sh -e native # override env (default: coverage)
|
||||
# ./bin/run-tests.sh --quiet # only print the final RESULT line
|
||||
# ./bin/run-tests.sh --write-manifest # print the test/state-manifest.tsv entries this run
|
||||
# # would need, for a human to paste and justify
|
||||
# ./bin/run-tests.sh --keep-state # keep every suite's sandbox, not just the interesting ones
|
||||
# ./bin/run-tests.sh --shuffle # randomise suite order (seed from HEAD; printed)
|
||||
# ./bin/run-tests.sh --seed 12345 # replay an exact order (implies --shuffle)
|
||||
#
|
||||
# Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER, 3 = FILTERED.
|
||||
#
|
||||
# HOST. This is a Linux tool: bash 4+ (mapfile), GNU coreutils and GNU find (`-printf`, md5sum,
|
||||
# `-executable`). That is a deliberate choice, not an oversight - the alternative is a second,
|
||||
# untested code path per host, and a state check that silently degrades is worse than one that does
|
||||
# not run. It is enforced below rather than left to be discovered. macOS and Windows are supported as
|
||||
# *build* targets by CI, not as hosts for this harness; run it in a container there, via
|
||||
# ./bin/test-native-docker.sh.
|
||||
#
|
||||
# -f IS NOT A GATE. A filtered run can pass while a full run fails: filtering removes the suites
|
||||
# that create the shared state a later suite trips over. Use -f to iterate; gate on a full run.
|
||||
#
|
||||
# Verdicts:
|
||||
# GREEN — all canonical suites ran, all passed, no ignored test cases.
|
||||
# AMBER — all that ran passed, but something was lost: a suite silently went missing on a
|
||||
# full run, or individual test cases were skipped (Unity TEST_IGNORE / :IGNORE:).
|
||||
# FILTERED — a -f run completed cleanly; suites not in the filter were intentionally skipped.
|
||||
# GREEN - all canonical suites ran, all passed, no ignored test cases, no undeclared leftovers.
|
||||
# AMBER - all that ran passed, but something was lost or unexplained: a suite silently went
|
||||
# missing on a full run, individual test cases were skipped (Unity TEST_IGNORE /
|
||||
# :IGNORE:), or a suite left behind shared state it does not declare in
|
||||
# test/state-manifest.tsv.
|
||||
# FILTERED - a -f run completed cleanly; suites not in the filter were intentionally skipped.
|
||||
# Use this when iterating on a single suite; it is not a quality signal.
|
||||
# RED — at least one failure, build error, or sanitizer fault.
|
||||
# RED - at least one failure, build error, or sanitizer fault.
|
||||
#
|
||||
# Two orthogonal axes: PASS/FAIL × CLEAN/DIRTY. Each suite runs in its own scratch $HOME
|
||||
# (bin/pio-test-isolate.sh), so leftovers are harmless; DIRTY means "undeclared", not "dangerous".
|
||||
#
|
||||
# ORDER. PlatformIO chooses suite order itself - list_test_names() walks test/ with os.walk() and
|
||||
# filters only *select*, they do not order - so --shuffle runs one `pio test -f <suite>` invocation
|
||||
# per suite in the chosen order. That costs about 4.7s per suite in extra pio startup. The seed is
|
||||
# printed on every shuffled run and derived from HEAD by default: deterministic for a given commit,
|
||||
# varied across commits, so a red is reproducible and attributable rather than flaky. A single green
|
||||
# seed is not evidence of order independence; vary it.
|
||||
#
|
||||
# Sanitizers, per env - this trips people up: `coverage` (the default here) has ASan/LSan;
|
||||
# `native` has NONE. Verified: zero ASan symbols in the native binary. `-e native` runs are not
|
||||
# sanitized, whatever the coverage wording elsewhere implies.
|
||||
#
|
||||
# The final line is machine-readable, e.g.:
|
||||
# RESULT: GREEN N/N suites passed
|
||||
# RESULT: AMBER N/M suites ran (missing: test_radio test_serial) — all that ran passed
|
||||
# RESULT: AMBER N/M suites ran (missing: test_radio test_serial) - all that ran passed
|
||||
# RESULT: AMBER 3 test case(s) ignored
|
||||
# RESULT: FILTERED 1/N suites ran (not run: …) — filtered: test_utf8
|
||||
# RESULT: FILTERED 1/N suites ran (not run: …) - filtered: test_utf8
|
||||
# RESULT: RED test_traffic_management: 1 failed (or: build/crash error)
|
||||
# RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
|
||||
# all passed; the coverage build aborts at exit on an ASan/LSan fault — often shown only
|
||||
# RESULT: RED sanitizer fault - SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
|
||||
# all passed; the coverage build aborts at exit on an ASan/LSan fault - often shown only
|
||||
# as [ERRORED]/SIGHUP. The script names it and points at running the binary bare.)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Refuse to start off Linux rather than fail somewhere in the middle. This harness is a Linux tool by
|
||||
# choice (see the HOST note in the header); on a BSD userland it would not fail cleanly, it would
|
||||
# mis-hash the sandbox, mis-read a suite list and report a verdict that looks real.
|
||||
if [[ $(uname -s) != Linux ]]; then
|
||||
echo "run-tests.sh is Linux-only (bash 4+, GNU coreutils, GNU find); this host is $(uname -s)." >&2
|
||||
echo "Run the suite in a container instead: ./bin/test-native-docker.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
cd "$ROOT_DIR" || exit 1
|
||||
|
||||
ENV="coverage"
|
||||
FILTER=""
|
||||
QUIET=false
|
||||
WRITE_MANIFEST=false
|
||||
KEEP_STATE=false
|
||||
SHUFFLE=false
|
||||
SEED=""
|
||||
PASSTHRU=()
|
||||
# Same passthrough args minus the -f pair. The shuffled loop supplies its own -f per suite, but
|
||||
# must still forward everything else the user gave (-v, -vvv, ...) - otherwise a shuffled run
|
||||
# builds with those flags and then runs without them.
|
||||
EXTRA_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
|
|
@ -59,8 +107,26 @@ while [[ $# -gt 0 ]]; do
|
|||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--write-manifest)
|
||||
WRITE_MANIFEST=true
|
||||
shift
|
||||
;;
|
||||
--keep-state)
|
||||
KEEP_STATE=true
|
||||
shift
|
||||
;;
|
||||
--shuffle)
|
||||
SHUFFLE=true
|
||||
shift
|
||||
;;
|
||||
--seed)
|
||||
SEED="$2"
|
||||
SHUFFLE=true
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
PASSTHRU+=("$1")
|
||||
EXTRA_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
|
|
@ -74,16 +140,35 @@ if [[ ! -x $PIO ]] && ! command -v "$PIO" >/dev/null 2>&1; then
|
|||
fi
|
||||
|
||||
LOG="$(mktemp -t meshtest.XXXXXX.log)"
|
||||
# Build output stays out of $LOG on purpose: the outcome regexes below match "error:" and
|
||||
# "[ERRORED]", so a compiler diagnostic in the same file would read as a test failure.
|
||||
BUILD_LOG="$(mktemp -t meshtest-build.XXXXXX.log)"
|
||||
MARKER=""
|
||||
PROGRESS_PID=""
|
||||
trap 'rm -f "$LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
|
||||
trap 'rm -f "$LOG" "$BUILD_LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
|
||||
|
||||
# --- Shared-state reporting ---------------------------------------------------
|
||||
# bin/pio-test-isolate.sh (wired in as test_testing_command) gives every suite its own scratch
|
||||
# $HOME and appends one line per suite here: suite, PASS/FAIL, CLEAN/DIRTY/MISSING, detail. The
|
||||
# wrapper enforces isolation on its own - a bare `pio test` gets it too - so all this section does
|
||||
# is collect and grade. Start from an empty summary so a stale one cannot be read as this run's.
|
||||
# shellcheck source=bin/lib/test-state.sh
|
||||
source "$SCRIPT_DIR/lib/test-state.sh"
|
||||
STATE_DIR="$ROOT_DIR/.pio/test-state"
|
||||
STATE_SUMMARY="$STATE_DIR/summary.tsv"
|
||||
rm -rf "$STATE_DIR"
|
||||
mkdir -p "$STATE_DIR"
|
||||
export MESHTASTIC_TEST_STATE_DIR="$STATE_DIR"
|
||||
export MESHTASTIC_TEST_STATE_SUMMARY="$STATE_SUMMARY"
|
||||
$KEEP_STATE && export MESHTASTIC_TEST_KEEP_STATE=1
|
||||
$WRITE_MANIFEST && export MESHTASTIC_TEST_KEEP_STATE=1
|
||||
|
||||
# Canonical suite set = the directories in test/. This is the source of truth for
|
||||
# "what should run"; a filtered run only expects its filtered suite.
|
||||
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
|
||||
EXPECTED_COUNT=${#ALL_SUITES[@]}
|
||||
|
||||
# Canonical suite count — the registered total, maintained in test/native-suite-count.
|
||||
# Canonical suite count - the registered total, maintained in test/native-suite-count.
|
||||
# Update that file whenever a test suite is added or removed.
|
||||
CANONICAL_COUNT_FILE="test/native-suite-count"
|
||||
if [[ -f $CANONICAL_COUNT_FILE ]]; then
|
||||
|
|
@ -98,7 +183,7 @@ fi
|
|||
BASELINE_FILE=".pio/build/${ENV}/.runtests-objcount"
|
||||
|
||||
# Progress trail file (gitignored build dir). ALWAYS written so a backgrounded/piped run can be
|
||||
# checked mid-build with `tail -f` — that's the whole point: don't fly blind on a 20-min rebuild.
|
||||
# checked mid-build with `tail -f` - that's the whole point: don't fly blind on a 20-min rebuild.
|
||||
PROGRESS_FILE=".pio/build/${ENV}/.runtests-progress"
|
||||
|
||||
# --- Progress heartbeat ------------------------------------------------------
|
||||
|
|
@ -114,16 +199,16 @@ progress_monitor() {
|
|||
el=$((now - start))
|
||||
if grep -q 'Testing\.\.\.' "$LOG" 2>/dev/null; then
|
||||
ran=$(grep -cE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" 2>/dev/null)
|
||||
line=$(printf '[test] %s/%s suites done — %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
|
||||
line=$(printf '[test] %s/%s suites done - %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
|
||||
else
|
||||
done=$(find ".pio/build/${ENV}" -name '*.o' -newer "$marker" 2>/dev/null | wc -l)
|
||||
if ((objtotal > 0 && done > 0)); then
|
||||
eta=$((objtotal > done ? (objtotal - done) * el / done : 0))
|
||||
line=$(printf '[build] %d/%d objs — %dm%02ds — ETA ~%dm%02ds' \
|
||||
line=$(printf '[build] %d/%d objs - %dm%02ds - ETA ~%dm%02ds' \
|
||||
"$done" "$objtotal" $((el / 60)) $((el % 60)) $((eta / 60)) $((eta % 60)))
|
||||
else
|
||||
# done==0 (incremental: nothing to rebuild yet) or no cached baseline — no ETA yet.
|
||||
line=$(printf '[build] %d objs compiled — %dm%02ds' "$done" $((el / 60)) $((el % 60)))
|
||||
# done==0 (incremental: nothing to rebuild yet) or no cached baseline - no ETA yet.
|
||||
line=$(printf '[build] %d objs compiled - %dm%02ds' "$done" $((el / 60)) $((el % 60)))
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$line" >>"$pfile" 2>/dev/null # file trail (always)
|
||||
|
|
@ -133,9 +218,12 @@ progress_monitor() {
|
|||
}
|
||||
|
||||
# Launch the heartbeat for every run. It writes the progress file unconditionally; the live tty
|
||||
# line only when interactive AND --quiet (where pio's own output is hidden — otherwise pio's
|
||||
# line only when interactive AND --quiet (where pio's own output is hidden - otherwise pio's
|
||||
# streamed compile lines already show progress and a \r line would just fight them).
|
||||
mkdir -p ".pio/build/${ENV}" 2>/dev/null || true
|
||||
# Clear last run's failure logs: a green run must not leave a red one's log lying around looking
|
||||
# current.
|
||||
rm -f ".pio/build/${ENV}/build-failure.log" ".pio/build/${ENV}/test-failure.log" 2>/dev/null || true
|
||||
: >"$PROGRESS_FILE" 2>/dev/null || true
|
||||
MARKER="$(mktemp -t meshtest-mark.XXXXXX)"
|
||||
TOTTY=0
|
||||
|
|
@ -149,30 +237,96 @@ if ! $QUIET; then
|
|||
fi
|
||||
echo "progress: tail -f $PROGRESS_FILE" >&2
|
||||
if [[ ! -t 1 ]] && ! $QUIET; then
|
||||
echo "hint: stdout is a pipe — build errors appear at the top of output and may be lost; use --quiet to get just the RESULT line" >&2
|
||||
echo "hint: stdout is a pipe - build errors appear at the top of output and may be lost; use --quiet to get just the RESULT line" >&2
|
||||
fi
|
||||
|
||||
# shuffle_suites() lives in lib/ because the CI workflow runs the same permutation; see the header
|
||||
# of that file for why a second copy cannot be allowed to exist.
|
||||
# shellcheck source=bin/lib/shuffle.sh
|
||||
source "$SCRIPT_DIR/lib/shuffle.sh"
|
||||
|
||||
RUN_ORDER=()
|
||||
if $SHUFFLE; then
|
||||
# Seed from HEAD when not given: same order for a given commit (so a PR's red is replayable and
|
||||
# attributable to its diff), different orders as the project moves.
|
||||
if [[ -z $SEED ]]; then
|
||||
SEED=$((16#$(git rev-parse --short=8 HEAD 2>/dev/null || echo 0)))
|
||||
fi
|
||||
if [[ -n $FILTER ]]; then
|
||||
mapfile -t RUN_ORDER < <(shuffle_suites "$SEED" "$FILTER")
|
||||
else
|
||||
mapfile -t RUN_ORDER < <(shuffle_suites "$SEED" "${ALL_SUITES[@]}")
|
||||
fi
|
||||
echo "suite order: shuffled with --seed $SEED (${#RUN_ORDER[@]} suites)"
|
||||
fi
|
||||
|
||||
# Build every test program before running any of them, the way .github/workflows/test_native.yml
|
||||
# does. Fused build+run makes whichever suite PlatformIO's directory walk reaches first absorb the
|
||||
# whole src compile and report it as its own duration - that is how a 35s suite once reported 13
|
||||
# minutes, and it hides the build cost from every timing the summary prints.
|
||||
BUILD_SECS=0
|
||||
build_started=$SECONDS
|
||||
if $QUIET; then
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-testing >"$BUILD_LOG" 2>&1
|
||||
BUILD_RC=$?
|
||||
else
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-testing 2>&1 | tee "$BUILD_LOG"
|
||||
BUILD_RC=${PIPESTATUS[0]}
|
||||
fi
|
||||
BUILD_SECS=$((SECONDS - build_started))
|
||||
if ((BUILD_RC != 0)); then
|
||||
# The grep below shows the first few diagnostics; the first error: is usually a cascade from
|
||||
# something further up, so keep the whole log rather than only what fits on screen.
|
||||
BUILD_FAIL_LOG=".pio/build/${ENV}/build-failure.log"
|
||||
cp "$BUILD_LOG" "$BUILD_FAIL_LOG" 2>/dev/null || BUILD_FAIL_LOG=""
|
||||
echo ""
|
||||
echo "RED - build failed before any suite ran:"
|
||||
grep -nE 'error:|undefined reference|\[ERRORED\]' "$BUILD_LOG" | head -5 | sed 's/^/ /'
|
||||
[[ -n $BUILD_FAIL_LOG ]] && echo " -> full build output: $BUILD_FAIL_LOG"
|
||||
echo "RESULT: RED build failed in ${BUILD_SECS}s (no suites ran)"
|
||||
exit 1
|
||||
fi
|
||||
if ! $QUIET; then
|
||||
echo "build: ${BUILD_SECS}s (shared by every suite; suite durations below exclude it)"
|
||||
fi
|
||||
|
||||
# Run pio, tee to log. PIPESTATUS[0] is pio's real exit (NOT tee's).
|
||||
if $QUIET; then
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" >"$LOG" 2>&1
|
||||
PIO_RC=0
|
||||
if $SHUFFLE; then
|
||||
# One invocation per suite: PlatformIO orders by its own directory walk, so this is the only way
|
||||
# to control it. Output is appended to the one $LOG the verdict logic already parses.
|
||||
: >"$LOG"
|
||||
for suite in "${RUN_ORDER[@]}"; do
|
||||
if $QUIET; then
|
||||
"$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" --without-building >>"$LOG" 2>&1
|
||||
rc=$?
|
||||
else
|
||||
"$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" --without-building 2>&1 | tee -a "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
fi
|
||||
((rc != 0)) && PIO_RC=$rc
|
||||
done
|
||||
elif $QUIET; then
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-building >"$LOG" 2>&1
|
||||
PIO_RC=$?
|
||||
else
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" 2>&1 | tee "$LOG"
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-building 2>&1 | tee "$LOG"
|
||||
PIO_RC=${PIPESTATUS[0]}
|
||||
fi
|
||||
PIO_RC=${PIPESTATUS[0]}
|
||||
|
||||
# Stop the heartbeat, clear its line, and cache this build's object total for next time.
|
||||
if [[ -n $PROGRESS_PID ]]; then
|
||||
kill "$PROGRESS_PID" 2>/dev/null
|
||||
wait "$PROGRESS_PID" 2>/dev/null
|
||||
PROGRESS_PID=""
|
||||
# Clear the live line only if we were writing one — opening /dev/tty when there is none is
|
||||
# Clear the live line only if we were writing one - opening /dev/tty when there is none is
|
||||
# itself a redirect-open error the trailing 2>/dev/null cannot suppress.
|
||||
[[ $TOTTY == 1 ]] && printf '\r\033[K' >/dev/tty 2>/dev/null
|
||||
fi
|
||||
[[ -d ".pio/build/${ENV}" ]] && find ".pio/build/${ENV}" -name '*.o' 2>/dev/null | wc -l >"$BASELINE_FILE" 2>/dev/null || true
|
||||
|
||||
# --- Outcome detection -------------------------------------------------------
|
||||
# The SAME outcome is spelled differently depending on which layer emitted the line — this is
|
||||
# The SAME outcome is spelled differently depending on which layer emitted the line - this is
|
||||
# the trap that produces false greens (grepping ":PASS" misses pio's "[PASSED]", grepping
|
||||
# "[FAILED]" misses Unity's ":FAIL:"). So every regex below matches BOTH spellings:
|
||||
# pass: Unity per-assertion ":PASS" | pio per-suite "[PASSED]" | summary "N succeeded"
|
||||
|
|
@ -184,7 +338,7 @@ FAIL_RE=':FAIL\b|\[FAILED\]|\[ERRORED\]|[1-9][0-9]* failed|[0-9]+ Tests [1-9][0-
|
|||
# the per-test/per-suite tokens OR a success summary line.
|
||||
PASS_RE=':PASS\b|\[PASSED\]|test cases: *[0-9]+ succeeded|[0-9]+ Tests 0 Failures'
|
||||
# Sanitizer (ASan/LSan/UBSan/TSan) fault signatures. The coverage build is sanitizer-instrumented
|
||||
# and aborts NON-ZERO at exit on a fault — most often a LeakSanitizer leak — AFTER every test has
|
||||
# and aborts NON-ZERO at exit on a fault - most often a LeakSanitizer leak - AFTER every test has
|
||||
# already printed [PASSED]. pio then reports [ERRORED]/SIGHUP with no :FAIL: anywhere, so it
|
||||
# masquerades as a phantom "N-1 of N succeeded". See .notes/test-passfail-filter.md.
|
||||
# Match only real FAULT lines, never the benign "AddressSanitizer: failed to intercept '...'"
|
||||
|
|
@ -203,35 +357,91 @@ RAN_COUNT=${#RAN_SUITES[@]}
|
|||
mapfile -t SKIPPED_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+.*\bSKIPPED\b" "$LOG" |
|
||||
grep -oE "test_[a-z0-9_]+" | sort -u)
|
||||
|
||||
# Keep the whole-run log, which the EXIT trap would otherwise delete. This is the cross-suite view
|
||||
# - order, pio-level output, what ran before the failure; bin/pio-test-isolate.sh separately keeps
|
||||
# the failing suite's own sandbox and log under .pio/test-state/<suite>/.
|
||||
preserve_run_log() {
|
||||
local dest=".pio/build/${ENV}/test-failure.log"
|
||||
cp "$LOG" "$dest" 2>/dev/null && echo " -> full run output: $dest"
|
||||
}
|
||||
|
||||
# PlatformIO prints one "N test cases: ... succeeded in T" line per invocation. A shuffled run is one
|
||||
# invocation per suite appending to the same $LOG, so taking the last line would report whatever the
|
||||
# LAST suite did - a failure in suite 3 printed under suite 44's "0 failed". Sum the lines instead.
|
||||
# One line in (the unshuffled case) is passed through verbatim, so the familiar output is unchanged.
|
||||
summarise_test_cases() {
|
||||
# The patterns are strings, not /regex/ literals: awk evaluates a regex literal passed as a
|
||||
# function argument as `$0 ~ /re/`, so the callee would receive 0 or 1 rather than a pattern.
|
||||
awk '
|
||||
function num(s, pat, m) {
|
||||
if (!match(s, pat)) return 0
|
||||
m = substr(s, RSTART, RLENGTH); gsub(/[^0-9]/, "", m); return m + 0
|
||||
}
|
||||
/test cases:/ {
|
||||
last = $0; n++
|
||||
cases += num($0, "[0-9]+ test cases")
|
||||
failed += num($0, "[0-9]+ failed")
|
||||
skipped += num($0, "[0-9]+ skipped")
|
||||
passed += num($0, "[0-9]+ succeeded")
|
||||
}
|
||||
END {
|
||||
if (n == 0) exit
|
||||
if (n == 1) { print " " last; exit }
|
||||
printf " %d test cases: ", cases
|
||||
if (failed) printf "%d failed, ", failed
|
||||
if (skipped) printf "%d skipped, ", skipped
|
||||
printf "%d succeeded, summed over %d suite invocations\n", passed, n
|
||||
}' "$1"
|
||||
}
|
||||
|
||||
verdict_red() {
|
||||
local detail bin
|
||||
# The order IS the diagnostic for an order-dependent failure; without it a shuffled red is
|
||||
# unreadable.
|
||||
if $SHUFFLE; then
|
||||
echo ""
|
||||
echo "suite order (--seed $SEED):"
|
||||
printf '%s\n' "${RUN_ORDER[@]}" | nl -ba | sed 's/^/ /'
|
||||
fi
|
||||
detail="$(grep -nE '\[FAILED\]|:FAIL:|\[ERRORED\]' "$LOG" | head -3 | sed 's/^/ /')"
|
||||
echo ""
|
||||
echo "RED — failures detected:"
|
||||
echo "RED - failures detected:"
|
||||
[[ -n $detail ]] && echo "$detail"
|
||||
grep -E 'test cases:' "$LOG" | tail -1 | sed 's/^/ /'
|
||||
summarise_test_cases "$LOG"
|
||||
preserve_run_log
|
||||
|
||||
# Path to the test binary for the "run it bare" hint. For native/coverage the test program is
|
||||
# the env executable (e.g. .pio/build/coverage/meshtasticd), NOT a file named 'program'.
|
||||
bin="$(find ".pio/build/${ENV}" -maxdepth 1 -type f -executable ! -name '*.so' 2>/dev/null | head -1)"
|
||||
[[ -z $bin ]] && bin=".pio/build/${ENV}/<program> (build it first: $PIO test -e ${ENV} ${FILTER:+-f $FILTER} --without-testing)"
|
||||
|
||||
# A signal name from this runner is almost never a crash. `exit(UNITY_END())` returns the
|
||||
# FAILURE COUNT, and PlatformIO's native runner renders a non-zero exit code as a POSIX signal:
|
||||
# 4 failures -> "Program received signal SIGILL", 5 -> SIGTRAP, and the suite is reported
|
||||
# [ERRORED] rather than [FAILED]. That is pure noise, and it cost hours of hunting a memory bug
|
||||
# that did not exist. Say so before anyone theorises.
|
||||
if grep -qE 'Program received signal SIG' "$LOG"; then
|
||||
echo " -> the signal name above is Unity's exit code, not a crash: exit(UNITY_END()) returns the"
|
||||
echo " failure count and the runner renders it as a signal number (4 -> SIGILL, 5 -> SIGTRAP)."
|
||||
echo " Match it against the failure count before assuming a fault; confirm any real crash in gdb."
|
||||
fi
|
||||
|
||||
# Sanitizer fault (ASan/LSan/UBSan/TSan): name the real cause instead of "build/crash error".
|
||||
if grep -qE "$SAN_RE" "$LOG"; then
|
||||
grep -nE "$SAN_RE" "$LOG" | head -4 | sed 's/^/ /'
|
||||
echo " -> sanitizer fault: if every test above is PASS, this is an exit-time abort, not a failed assertion."
|
||||
echo " -> read the full report by running the binary BARE (gdb hides it via ptrace): ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED sanitizer fault — $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
|
||||
echo "RESULT: RED sanitizer fault - $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the
|
||||
# sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a
|
||||
# sanitizer fault — point at how to surface it rather than calling it a generic crash.
|
||||
# sanitizer fault - point at how to surface it rather than calling it a generic crash.
|
||||
if grep -qE "$PASS_RE" "$LOG" && grep -qE '\[ERRORED\]|SIGHUP|SIGABRT' "$LOG" && ! grep -qE ':FAIL\b|\[FAILED\]' "$LOG"; then
|
||||
echo " -> all tests passed but the process aborted at EXIT — likely an ASan/LSan fault whose report"
|
||||
echo " -> all tests passed but the process aborted at EXIT - likely an ASan/LSan fault whose report"
|
||||
echo " the runner swallowed (commonly shown as SIGHUP). Run the binary BARE to see it: ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)"
|
||||
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer - see hint above)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -245,33 +455,90 @@ if [[ $PIO_RC -ne 0 ]] || grep -qE "$FAIL_RE" "$LOG"; then
|
|||
fi
|
||||
if ! grep -qE "$PASS_RE" "$LOG"; then
|
||||
echo ""
|
||||
echo "RESULT: RED no success summary found (build error / no tests ran?) — see log"
|
||||
# This path never runs verdict_red, and if the build died before any suite started there is no
|
||||
# per-suite sandbox either - so without preserving here, "see log" points at nothing.
|
||||
preserve_run_log
|
||||
echo "RESULT: RED no success summary found (build error / no tests ran?)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Canonical-count rating suffix — appended to every verdict line so the result is always
|
||||
# Canonical-count rating suffix - appended to every verdict line so the result is always
|
||||
# rated against the registered total, not just the directory count.
|
||||
# If the two counts diverge (suite added/removed without updating native-suite-count), that
|
||||
# is itself surfaced as AMBER before we reach any verdict.
|
||||
canonical_rating() {
|
||||
local rating=""
|
||||
if [[ -n $CANONICAL_COUNT ]]; then
|
||||
echo "[canonical: ${RAN_COUNT}/${CANONICAL_COUNT}]"
|
||||
rating="[canonical: ${RAN_COUNT}/${CANONICAL_COUNT}]"
|
||||
fi
|
||||
# Carry the seed into the machine-readable line so a verdict is always replayable from it alone.
|
||||
$SHUFFLE && rating="$rating [seed: $SEED]"
|
||||
echo "$rating"
|
||||
}
|
||||
|
||||
# AMBER: directory count disagrees with native-suite-count — file needs updating.
|
||||
# AMBER: directory count disagrees with native-suite-count - file needs updating.
|
||||
if [[ -n $CANONICAL_COUNT && $EXPECTED_COUNT -ne $CANONICAL_COUNT ]]; then
|
||||
echo ""
|
||||
if [[ $EXPECTED_COUNT -gt $CANONICAL_COUNT ]]; then
|
||||
echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT — update test/native-suite-count after registering new suites"
|
||||
echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after registering new suites"
|
||||
else
|
||||
echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT — update test/native-suite-count after removing suites"
|
||||
echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after removing suites"
|
||||
fi
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# --- Shared-state axis --------------------------------------------------------
|
||||
# Read what the per-suite wrapper recorded. Reported after the count checks so a structural problem
|
||||
# still wins, and before the pass/fail verdict lines so the state summary always prints.
|
||||
DIRTY_SUITES=()
|
||||
MISSING_SUITES=()
|
||||
SURVIVOR_SUITES=()
|
||||
if [[ -f $STATE_SUMMARY ]]; then
|
||||
mapfile -t DIRTY_SUITES < <(awk -F'\t' '$3 == "DIRTY" { print $1 " (" $4 ")" }' "$STATE_SUMMARY")
|
||||
mapfile -t MISSING_SUITES < <(awk -F'\t' '$3 == "MISSING" { print $1 " (" $4 ")" }' "$STATE_SUMMARY")
|
||||
mapfile -t SURVIVOR_SUITES < <(awk -F'\t' '$6 != "" { print $1 " (pid " $6 ")" }' "$STATE_SUMMARY")
|
||||
fi
|
||||
|
||||
# Print the opt-out count on every run, so the number creeping upward is visible without anyone
|
||||
# auditing test/state-manifest.tsv on purpose.
|
||||
DECLARED_COUNT=0
|
||||
if [[ -f $ROOT_DIR/$STATE_MANIFEST_DEFAULT ]]; then
|
||||
DECLARED_COUNT=$(grep -cvE '^[[:space:]]*(#|$)' "$ROOT_DIR/$STATE_MANIFEST_DEFAULT" || true)
|
||||
fi
|
||||
if ! $QUIET; then
|
||||
echo ""
|
||||
echo "shared state: $DECLARED_COUNT suite(s) declare non-default state handling (test/state-manifest.tsv)"
|
||||
fi
|
||||
|
||||
# --write-manifest: propose, never apply. An auto-accepted baseline is the same rot as an
|
||||
# auto-updated snapshot, so this prints lines for a human to paste AND justify - the reason column
|
||||
# is the point, and only a person can write it.
|
||||
if $WRITE_MANIFEST; then
|
||||
echo ""
|
||||
echo "Proposed test/state-manifest.tsv entries from this run (paste and replace <why>):"
|
||||
if [[ -f $STATE_SUMMARY ]]; then
|
||||
awk -F'\t' '$3 == "DIRTY" {
|
||||
detail = $4; sub(/^undeclared: /, "", detail);
|
||||
n = split(detail, paths, " "); out = "";
|
||||
for (i = 1; i <= n; i++) { base = paths[i]; sub(/^.*\//, "", base); out = out (i > 1 ? "," : "") base }
|
||||
printf "%s\twrites=%s\t<why>\n", $1, out
|
||||
}' "$STATE_SUMMARY" | sort -u | sed 's/^/ /'
|
||||
fi
|
||||
echo ""
|
||||
echo " Sandboxes kept under $STATE_DIR/<suite>/ - the leftovers themselves are the evidence."
|
||||
fi
|
||||
|
||||
# MISSING is a warning, never a verdict: a declared write that did not happen catches silently
|
||||
# broken persistence (the upstream TAK config bug was a has_ flag never set, so the save wrote
|
||||
# nothing and no test noticed), but some declared writes are legitimately conditional.
|
||||
if ((${#MISSING_SUITES[@]} > 0)) && ! $QUIET; then
|
||||
echo ""
|
||||
echo "warning: declared writes that did not happen - check for silently broken persistence:"
|
||||
printf ' %s\n' "${MISSING_SUITES[@]}"
|
||||
fi
|
||||
|
||||
# AMBER: individual test cases were skipped (Unity TEST_IGNORE → :IGNORE: in output).
|
||||
# Applies to both full and filtered runs — a skipped test case is a lost signal either way.
|
||||
# Applies to both full and filtered runs - a skipped test case is a lost signal either way.
|
||||
mapfile -t IGNORED_TESTS < <(grep -oE '[^:]+:[0-9]+:[^:]+:IGNORE:.*' "$LOG" 2>/dev/null | sed 's/:IGNORE:.*//' | sort -u)
|
||||
IGNORED_COUNT=${#IGNORED_TESTS[@]}
|
||||
if [[ $IGNORED_COUNT -gt 0 ]]; then
|
||||
|
|
@ -283,7 +550,7 @@ if [[ $IGNORED_COUNT -gt 0 ]]; then
|
|||
exit 2
|
||||
fi
|
||||
|
||||
# AMBER: full run only — a canonical suite neither ran NOR was explicitly skipped (silently missing).
|
||||
# AMBER: full run only - a canonical suite neither ran NOR was explicitly skipped (silently missing).
|
||||
ACCOUNTED_COUNT=$((RAN_COUNT + ${#SKIPPED_SUITES[@]}))
|
||||
if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
|
||||
missing=()
|
||||
|
|
@ -291,7 +558,37 @@ if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
|
|||
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s")
|
||||
done
|
||||
echo ""
|
||||
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) — all that ran passed $(canonical_rating)"
|
||||
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) - all that ran passed $(canonical_rating)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# AMBER: a suite mutated shared state it does not declare. Per-suite isolation means this is no
|
||||
# longer dangerous - nothing survives the suite boundary - so it is graded AMBER rather than RED:
|
||||
# it means "undeclared", not "broken". Applies to filtered runs too, because a suite writing state
|
||||
# nobody declared is a finding whether or not its neighbours ran.
|
||||
if ((${#DIRTY_SUITES[@]} > 0)); then
|
||||
echo ""
|
||||
printf ' %s\n' "${DIRTY_SUITES[@]}"
|
||||
echo ""
|
||||
echo " -> declare these in test/state-manifest.tsv with a reason, or stop the write."
|
||||
echo " -> ./bin/run-tests.sh --write-manifest prints the entries to paste."
|
||||
echo "RESULT: AMBER ${#DIRTY_SUITES[@]} suite(s) left undeclared shared state $(canonical_rating)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# AMBER: a suite was still running after PlatformIO reported it. A bare UNITY_END() ends the
|
||||
# reporting, not the process - the runtime goes on calling loop() - so the suite passes, the run goes
|
||||
# green, and the binary stays resident. The wrapper has already killed it, but the consequences do
|
||||
# not undo: its CLEAN/DIRTY verdict was measured against a tree it may still have been writing to,
|
||||
# and .gcda plus LeakSanitizer both flush from atexit handlers that never ran, so the suite silently
|
||||
# contributed no coverage and got no leak check. AMBER, not RED - the tests themselves did pass.
|
||||
if ((${#SURVIVOR_SUITES[@]} > 0)); then
|
||||
echo ""
|
||||
printf ' %s\n' "${SURVIVOR_SUITES[@]}"
|
||||
echo ""
|
||||
echo " -> end every setup() branch with exit(UNITY_END()), not a bare UNITY_END()."
|
||||
echo " -> ./bin/lint-unity-exit.sh test/**/*.cpp finds the sites; see test/README.md."
|
||||
echo "RESULT: AMBER ${#SURVIVOR_SUITES[@]} suite(s) still running after the suite finished $(canonical_rating)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
|
@ -302,10 +599,10 @@ if [[ -n $FILTER ]]; then
|
|||
for s in "${ALL_SUITES[@]}"; do
|
||||
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || not_run+=("$s")
|
||||
done
|
||||
echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) — filtered: $FILTER $(canonical_rating)"
|
||||
echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) - filtered: $FILTER $(canonical_rating)"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# GREEN: all canonical suites ran, all passed, no ignored test cases.
|
||||
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed $(canonical_rating)"
|
||||
# GREEN: all canonical suites ran, all passed, no ignored test cases, nothing undeclared left behind.
|
||||
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed, all CLEAN $(canonical_rating)"
|
||||
exit 0
|
||||
|
|
|
|||
101
bin/test-lint-unity-exit.sh
Executable file
101
bin/test-lint-unity-exit.sh
Executable file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env bash
|
||||
# Self-test for bin/lint-unity-exit.sh.
|
||||
#
|
||||
# This exists because the scanner has been wrong twice in review, both times in a way that looked
|
||||
# fine by inspection: layered regexes cannot tokenise C++, so a `/*` inside a string literal flipped
|
||||
# comment state, a greedy `.*` swallowed code between two comments, `myexit(...)` matched the `exit`
|
||||
# exemption as a substring, and `==` matched the assignment exemption. Every one of those is pinned
|
||||
# below as a fixture, so the next rewrite has to keep them all passing.
|
||||
#
|
||||
# Each fixture is a snippet of C++ plus the exact diagnostics it must produce, as a comma-separated
|
||||
# list of <line>:<col> - empty for none. Asserting the locations rather than just "did it say
|
||||
# anything" is what catches a rule that reports the right number of findings in the wrong places, or
|
||||
# that collapses two findings on one line into one.
|
||||
#
|
||||
# Not a Unity suite and not counted in test/native-suite-count - same arrangement as
|
||||
# bin/test-state-check.sh, and for the same reason: it asserts the behaviour of a process.
|
||||
#
|
||||
# Usage: ./bin/test-lint-unity-exit.sh (exit 0 = all fixtures behaved)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR" || exit 1
|
||||
|
||||
WORK="$(mktemp -d -t meshlintunity.XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$WORK/test/probe"
|
||||
|
||||
PASSES=0
|
||||
FAILURES=0
|
||||
|
||||
# expect "<line>:<col>[,<line>:<col>...]" <label> <body> ("" = no diagnostics expected)
|
||||
expect() {
|
||||
local want="$1" label="$2" body="$3"
|
||||
local f="$WORK/test/probe/case.cpp"
|
||||
printf '%s\n' "$body" >"$f"
|
||||
|
||||
# Reduce each diagnostic to line:col. The message text is asserted once, separately, so a
|
||||
# reworded message does not churn every fixture here.
|
||||
local got
|
||||
got="$(cd "$WORK" && "$SCRIPT_DIR/lint-unity-exit.sh" test/probe/case.cpp |
|
||||
awk -F: '{printf "%s%s:%s", (NR > 1 ? "," : ""), $2, $3}')"
|
||||
|
||||
if [[ $got == "$want" ]]; then
|
||||
echo " PASS $label - [${want:-none}]"
|
||||
PASSES=$((PASSES + 1))
|
||||
else
|
||||
echo " FAIL $label - expected [${want:-none}], got [${got:-none}]"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Terminating forms (must NOT be reported):"
|
||||
expect "" "exit(UNITY_END()) on one line" 'void s() { UNITY_BEGIN(); exit(UNITY_END()); }'
|
||||
expect "" "exit( and the macro on separate lines" 'void s() {
|
||||
exit(
|
||||
UNITY_END());
|
||||
}'
|
||||
expect "" "capture-then-exit" 'void s() { const int rc = UNITY_END(); restore(); exit(rc); }'
|
||||
|
||||
echo
|
||||
echo "Bare calls (MUST be reported):"
|
||||
expect "1:27" "plain bare call" 'void s() { UNITY_BEGIN(); UNITY_END(); }'
|
||||
expect "2:27" "bare call in an #else branch" '#else
|
||||
void s() { UNITY_BEGIN(); UNITY_END(); }
|
||||
#endif'
|
||||
# `return` finalises the report and returns a count; it does not terminate the runner, and there is
|
||||
# no main() under test/ from which it would.
|
||||
expect "1:19" "return UNITY_END() does not terminate" 'void s() { return UNITY_END(); }'
|
||||
# `exit` must be a whole identifier, not a suffix of some other function.
|
||||
expect "1:19" "myexit(UNITY_END()) is not exit()" 'void s() { myexit(UNITY_END()); }'
|
||||
# The assignment exemption is for capture; comparison and compound assignment are not capture.
|
||||
expect "1:21" "== is not an assignment" 'void s() { if (x == UNITY_END()) return; }'
|
||||
expect "1:21" "+= is not an assignment" 'void s() { total += UNITY_END(); }'
|
||||
|
||||
echo
|
||||
echo "Comments and literals (the two classes that broke it before):"
|
||||
expect "" "line comment mentioning the macro" 'void s() { exit(UNITY_END()); } // call UNITY_END() at the end'
|
||||
expect "" "block comment interior mentioning the macro" '/* a comment
|
||||
that mentions UNITY_END()
|
||||
across lines */
|
||||
void s() { exit(UNITY_END()); }'
|
||||
expect "" "the macro inside a string literal" 'void s() { TEST_MESSAGE("call UNITY_END() when done"); exit(UNITY_END()); }'
|
||||
expect "1:34" "a string containing /* must not open a comment" 'void s() { const char *p = "/*"; UNITY_END(); }'
|
||||
expect "1:24" "code between two block comments on one line" 'void s() { /* first */ UNITY_END(); /* second */ }'
|
||||
expect "1:36" "escaped quote inside a string does not end it" 'void s() { const char *p = "a\"b"; UNITY_END(); }'
|
||||
|
||||
echo
|
||||
echo "Multiple occurrences on one line (count and caret must both be right):"
|
||||
# The caret must land on the BARE call at column 31, not the wrapped one at 17.
|
||||
expect "1:31" "one wrapped and one bare on the same line" 'void s() { exit(UNITY_END()); UNITY_END(); }'
|
||||
expect "1:12,1:25" "two bare calls on one line report twice" 'void s() { UNITY_END(); UNITY_END(); }'
|
||||
|
||||
echo
|
||||
if ((FAILURES > 0)); then
|
||||
echo "RESULT: RED lint-unity-exit self-test - $FAILURES of $((PASSES + FAILURES)) fixtures behaved unexpectedly"
|
||||
exit 1
|
||||
fi
|
||||
echo "RESULT: GREEN lint-unity-exit self-test - $PASSES/$PASSES fixtures behaved as specified"
|
||||
exit 0
|
||||
173
bin/test-state-check.sh
Executable file
173
bin/test-state-check.sh
Executable file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env bash
|
||||
# Self-test for the shared-state checker in bin/pio-test-isolate.sh.
|
||||
#
|
||||
# A checker that silently matches everything passes forever and nobody finds out - which is exactly
|
||||
# how the leak it exists to catch survived. So it ships with fixtures: stand-in "suites" that write
|
||||
# nothing, write exactly what they declare, write something undeclared, and declare a write they
|
||||
# never make, asserting CLEAN / CLEAN / DIRTY / MISSING respectively. Plus one that proves the
|
||||
# before-empty assertion fires, because an after-diff measured against a dirty baseline reports
|
||||
# green while meaning nothing.
|
||||
#
|
||||
# Not a Unity suite and not counted in test/native-suite-count - the same arrangement as
|
||||
# bin/test-config-check.sh, and for the same reason: what it asserts is the behaviour of a process,
|
||||
# not of a linkable function.
|
||||
#
|
||||
# Usage: ./bin/test-state-check.sh (exit 0 = all fixtures behaved)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR" || exit 1
|
||||
|
||||
WORK="$(mktemp -d -t meshstatecheck.XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
PREFS=".portduino/default/prefs"
|
||||
MANIFEST="$WORK/manifest.tsv"
|
||||
cat >"$MANIFEST" <<EOF
|
||||
# suite flags reason
|
||||
test_fixture_declared writes=nodes.proto fixture: writes exactly what it declares
|
||||
test_fixture_undeclared writes=nodes.proto fixture: declares one file and writes two
|
||||
test_fixture_missing writes=warm.dat fixture: declares a write it never makes
|
||||
EOF
|
||||
|
||||
# Stand-in for a suite binary. Writes the files named in FIXTURE_WRITES into its own $HOME, and
|
||||
# prints a Unity-shaped line so the wrapper can recover the suite name the same way it does for a
|
||||
# real suite.
|
||||
FAKE="$WORK/fake-suite.sh"
|
||||
cat >"$FAKE" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "test/${FIXTURE_SUITE}/test_main.cpp:1:test_fixture:PASS"
|
||||
mkdir -p "$HOME/.portduino/default/prefs"
|
||||
for f in ${FIXTURE_WRITES:-}; do
|
||||
printf 'fixture payload\n' > "$HOME/.portduino/default/prefs/$f"
|
||||
done
|
||||
exit "${FIXTURE_RC:-0}"
|
||||
EOF
|
||||
chmod +x "$FAKE"
|
||||
|
||||
PASSES=0
|
||||
FAILURES=0
|
||||
|
||||
# Run one fixture through the real wrapper and compare the verdict it recorded.
|
||||
expect_verdict() {
|
||||
local label="$1" suite="$2" writes="$3" want="$4"
|
||||
local state_dir="$WORK/state-$suite"
|
||||
local summary="$state_dir/summary.tsv"
|
||||
rm -rf "$state_dir"
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
FIXTURE_SUITE="$suite" FIXTURE_WRITES="$writes" \
|
||||
MESHTASTIC_TEST_STATE_DIR="$state_dir" \
|
||||
MESHTASTIC_TEST_STATE_SUMMARY="$summary" \
|
||||
MESHTASTIC_TEST_STATE_MANIFEST="$MANIFEST" \
|
||||
"$SCRIPT_DIR/pio-test-isolate.sh" "$FAKE" >/dev/null 2>&1
|
||||
|
||||
local got
|
||||
got="$(awk -F'\t' -v s="$suite" '$1 == s { print $3; exit }' "$summary" 2>/dev/null)"
|
||||
if [[ $got == "$want" ]]; then
|
||||
echo " PASS $label - $want"
|
||||
PASSES=$((PASSES + 1))
|
||||
else
|
||||
echo " FAIL $label - expected $want, got '${got:-<no entry>}'"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Fixture suites (verdict axis):"
|
||||
expect_verdict "writes nothing" test_fixture_clean "" CLEAN
|
||||
expect_verdict "writes what it declares" test_fixture_declared "nodes.proto" CLEAN
|
||||
expect_verdict "writes something undeclared" test_fixture_undeclared "nodes.proto warm.dat" DIRTY
|
||||
expect_verdict "declares a write it skips" test_fixture_missing "" MISSING
|
||||
|
||||
# Survivor axis. Stands in for a suite that ends on a bare UNITY_END(): it prints its Unity line and
|
||||
# returns, but leaves a process running inside the sandbox $HOME, exactly as the runtime's loop()
|
||||
# does. Asserts the wrapper both records it and kills it - a detector that reports without reaping
|
||||
# would leave the host accumulating processes, which is half the harm.
|
||||
echo
|
||||
echo "Survivor axis (state_find_survivors):"
|
||||
LEAKY="$WORK/leaky-suite.sh"
|
||||
cat >"$LEAKY" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "test/${FIXTURE_SUITE}/test_main.cpp:1:test_fixture:PASS"
|
||||
mkdir -p "$HOME/.portduino/default/prefs"
|
||||
# Detached from this shell's stdout so the wrapper's `| tee` sees EOF and the pipeline returns -
|
||||
# the survivor outlives the suite exactly as a spun loop() does.
|
||||
setsid sleep 300 >/dev/null 2>&1 &
|
||||
printf '%s\n' "$!" > "$HOME/../survivor.pid"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LEAKY"
|
||||
|
||||
survivor_dir="$WORK/state-survivor"
|
||||
mkdir -p "$survivor_dir"
|
||||
FIXTURE_SUITE=test_fixture_survivor \
|
||||
MESHTASTIC_TEST_STATE_DIR="$survivor_dir" \
|
||||
MESHTASTIC_TEST_STATE_SUMMARY="$survivor_dir/summary.tsv" \
|
||||
MESHTASTIC_TEST_STATE_MANIFEST="$MANIFEST" \
|
||||
"$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>&1
|
||||
|
||||
recorded="$(awk -F'\t' '$1 == "test_fixture_survivor" { print $6; exit }' "$survivor_dir/summary.tsv" 2>/dev/null)"
|
||||
if [[ -n ${recorded// /} ]]; then
|
||||
echo " PASS a process outliving the suite is reported"
|
||||
PASSES=$((PASSES + 1))
|
||||
else
|
||||
echo " FAIL a process outliving the suite went unreported"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
|
||||
# Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames
|
||||
# its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed. Assert the
|
||||
# file was found BEFORE asserting the process is gone - otherwise an empty pid takes the "not
|
||||
# running" branch and the check passes without having checked anything.
|
||||
leaked_pid="$(cat "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null | head -1)"
|
||||
if [[ -z $leaked_pid ]]; then
|
||||
echo " FAIL no survivor pid recorded - the reap assertion would pass vacuously"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
elif kill -0 "$leaked_pid" 2>/dev/null; then
|
||||
echo " FAIL the survivor was reported but left running (pid $leaked_pid)"
|
||||
kill -9 "$leaked_pid" 2>/dev/null
|
||||
FAILURES=$((FAILURES + 1))
|
||||
else
|
||||
echo " PASS the survivor is reaped, not just reported (pid $leaked_pid)"
|
||||
PASSES=$((PASSES + 1))
|
||||
fi
|
||||
|
||||
# Guard the guard. The wrapper mktemp's its own sandbox name, so the leak cannot be staged through
|
||||
# it; exercise the assertion the wrapper actually calls instead - same function, same code path.
|
||||
echo
|
||||
echo "Before-empty assertion (state_assert_empty):"
|
||||
# shellcheck source=bin/lib/test-state.sh
|
||||
source "$SCRIPT_DIR/lib/test-state.sh"
|
||||
|
||||
seeded="$WORK/seeded"
|
||||
mkdir -p "$seeded/$PREFS"
|
||||
printf 'stale\n' >"$seeded/$PREFS/nodes.proto"
|
||||
if state_assert_empty "$seeded" 2>/dev/null; then
|
||||
echo " FAIL a dirty sandbox was accepted - the after-diff would measure against the wrong baseline"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
else
|
||||
echo " PASS a dirty sandbox is refused"
|
||||
PASSES=$((PASSES + 1))
|
||||
fi
|
||||
|
||||
empty="$WORK/empty"
|
||||
mkdir -p "$empty"
|
||||
if state_assert_empty "$empty" 2>/dev/null; then
|
||||
echo " PASS an empty sandbox is accepted"
|
||||
PASSES=$((PASSES + 1))
|
||||
else
|
||||
echo " FAIL an empty sandbox was refused - the assertion matches everything"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
|
||||
echo
|
||||
if ((FAILURES > 0)); then
|
||||
echo "RESULT: RED state-checker self-test - $FAILURES of $((PASSES + FAILURES)) fixtures behaved unexpectedly"
|
||||
exit 1
|
||||
fi
|
||||
echo "RESULT: GREEN state-checker self-test - $PASSES/$PASSES fixtures behaved as specified"
|
||||
exit 0
|
||||
|
|
@ -59,16 +59,32 @@ part is deliberately class-deviant and the reason is given under the table.
|
|||
warm-evicted signer be impersonated with unsigned frames.
|
||||
- `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`.
|
||||
|
||||
**Capacity** - `MAX_NUM_NODES` (`mesh-pb-constants.h`):
|
||||
**Capacity** - `MAX_NUM_NODES`:
|
||||
|
||||
| ESP32-S3 | Native | nRF52840, generic ESP32 | STM32WL |
|
||||
| --------------- | ------ | ----------------------- | ------- |
|
||||
| 250 / 200 / 100 | 250 | 120 | 10 |
|
||||
| ESP32-S3 | Native (portduino) | nRF52840, generic ESP32 | STM32WL |
|
||||
| --------------- | ------------------ | ----------------------- | ------- |
|
||||
| 250 / 200 / 100 | 200, configurable | 120 | 10 |
|
||||
|
||||
This one is flash-shaped rather than heap-shaped, so it is unclassed: `nodes.proto` has to fit the
|
||||
filesystem. ESP32-S3 is the only runtime tier, picked at boot from the flash chip (>=15 MB />=7 MB
|
||||
/ smaller); the 120 covers nRF52840 plus generic ESP32 including C3, and is what keeps `nodes.proto`
|
||||
inside the stock 28 KB LittleFS.
|
||||
filesystem. The fixed-cap platforms get their value from `mesh-pb-constants.h`; the 120 covers
|
||||
nRF52840 plus generic ESP32 including C3, and is what keeps `nodes.proto` inside the stock 28 KB
|
||||
LittleFS.
|
||||
|
||||
**Two platforms do not take their cap from that header, and neither is a compile-time constant:**
|
||||
|
||||
- **ESP32-S3** picks a tier at boot from the flash chip size (>=15 MB / >=7 MB / smaller).
|
||||
- **Native/portduino** resolves it from _runtime_ config:
|
||||
`variants/native/portduino{,-buildroot}/variant.h` define `MAX_NUM_NODES portduino_config.MaxNodes`,
|
||||
default **200** (`PortduinoGlue.h`), overridable per-host with `General: MaxNodes` in the YAML.
|
||||
Because `variant.h` is reached first, the `ARCH_PORTDUINO` branch of `mesh-pb-constants.h` never
|
||||
fires - it is `#error`-guarded so it can no longer be misread as the native cap.
|
||||
|
||||
Do not grep `mesh-pb-constants.h` for the native number: the protected-node cap derives from
|
||||
`MAX_NUM_NODES` (`numProtectedNodes() < MAX_NUM_NODES - 2`), so a wrong reading gives a wrong cap
|
||||
(248 instead of 198) and makes a genuinely saturated database look impossible.
|
||||
|
||||
The separate `250` in `NodeDB::getMaxNodesAllocatedSize()` is `NODEDB_MIGRATION_LOAD_CEILING`, a
|
||||
decode allowance for files written by larger-cap firmware. It is not a cap on this build.
|
||||
|
||||
## 2. Warm tier - `WarmNodeStore` (NodeDB-owned)
|
||||
|
||||
|
|
@ -257,26 +273,30 @@ behaviour - is documented with the module in
|
|||
Side-by-side view of what each store actually holds ("-" = not held). Details and
|
||||
rationale live in the per-store sections above.
|
||||
|
||||
| Property | 1. Hot store | 2. Warm tier | 3. NodeInfo cache | 4. Unified cache |
|
||||
| -------------------------- | ------------------------------ | ------------------------------ | ---------------------------------- | ------------------------------- |
|
||||
| Struct | `NodeInfoLite` | `WarmNodeEntry` | `NodeInfoPayloadEntry` | `UnifiedCacheEntry` |
|
||||
| Node number | yes | yes | yes (0 = free) | yes (0 = free) |
|
||||
| Names + user id | yes (flattened) | - | yes (full `User`) | - |
|
||||
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU/proven; pinned) | - |
|
||||
| Key source - XEdDSA signed | `HAS_XEDDSA_SIGNED` bit | 1 bit (in `last_heard`) | `keyXeddsaSigned` | - |
|
||||
| Key source - manual scan | `IS_KEY_MANUALLY_VERIFIED` bit | - (not carried) | `keyManuallyVerified` | - |
|
||||
| Device role | `role` field | 4-bit role (metadata steal) | in cached `User` | 4-bit role (final fallback) |
|
||||
| Recency | `last_heard` (unix s) | `last_heard` (128 s quant.) | `obsTick` (3 min) + `hasObserved` | modular ticks |
|
||||
| Position / telemetry | satellite accessors | - | - | 8-bit pos fingerprint (dedup) |
|
||||
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` instead) | - |
|
||||
| Routing hint (`next_hop`) | yes (persisted) | - | - | ACK-confirmed relay byte |
|
||||
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` | - |
|
||||
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fp |
|
||||
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8 (padded) | 10 B exact |
|
||||
| Capacity (symbol) | `MAX_NUM_NODES` | `WARM_NODE_COUNT` | `kNodeInfoCacheEntries` | `TRAFFIC_MANAGEMENT_CACHE_SIZE` |
|
||||
| Capacity (entries) | 250/120/10 | ~100 | 2000 | 2048/500/400/250/0 |
|
||||
| Persistence (durable) | LittleFS (node DB) | flash ring (nRF52840)/LittleFS | none (rebuilt) | none |
|
||||
| Storage (runtime) | heap | heap / PSRAM (ESP32) | PSRAM (hw) / heap (test) | PSRAM / heap |
|
||||
| Property | 1. Hot store | 2. Warm tier | 3. NodeInfo cache | 4. Unified cache |
|
||||
| -------------------------- | ---------------------------------- | ------------------------------ | ---------------------------------- | ------------------------------- |
|
||||
| Struct | `NodeInfoLite` | `WarmNodeEntry` | `NodeInfoPayloadEntry` | `UnifiedCacheEntry` |
|
||||
| Node number | yes | yes | yes (0 = free) | yes (0 = free) |
|
||||
| Names + user id | yes (flattened) | - | yes (full `User`) | - |
|
||||
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU/proven; pinned) | - |
|
||||
| Key source - XEdDSA signed | `HAS_XEDDSA_SIGNED` bit | 1 bit (in `last_heard`) | `keyXeddsaSigned` | - |
|
||||
| Key source - manual scan | `IS_KEY_MANUALLY_VERIFIED` bit | - (not carried) | `keyManuallyVerified` | - |
|
||||
| Device role | `role` field | 4-bit role (metadata steal) | in cached `User` | 4-bit role (final fallback) |
|
||||
| Recency | `last_heard` (unix s) | `last_heard` (128 s quant.) | `obsTick` (3 min) + `hasObserved` | modular ticks |
|
||||
| Position / telemetry | satellite accessors | - | - | 8-bit pos fingerprint (dedup) |
|
||||
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` instead) | - |
|
||||
| Routing hint (`next_hop`) | yes (persisted) | - | - | ACK-confirmed relay byte |
|
||||
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` | - |
|
||||
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fp |
|
||||
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8 (padded) | 10 B exact |
|
||||
| Capacity (symbol) | `MAX_NUM_NODES` | `WARM_NODE_COUNT` | `kNodeInfoCacheEntries` | `TRAFFIC_MANAGEMENT_CACHE_SIZE` |
|
||||
| Capacity (entries) | 250/200/120/100/10 (native: 200\*) | ~100 | 2000 | 2048/500/400/250/0 |
|
||||
| Persistence (durable) | LittleFS (node DB) | flash ring (nRF52840)/LittleFS | none (rebuilt) | none |
|
||||
| Storage (runtime) | heap | heap / PSRAM (ESP32) | PSRAM (hw) / heap (test) | PSRAM / heap |
|
||||
|
||||
\* Native/portduino is not a compile-time value: it is `portduino_config.MaxNodes`; the host default
|
||||
is 200, settable per-host via `General: MaxNodes`, and the WASM build overrides it to 80
|
||||
(`wasm_config_apply()`). See the hot-store capacity section above.
|
||||
|
||||
## How a lookup falls through the tiers
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
#include "SerialConsole.h"
|
||||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "Default.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "SerialConsole.h"
|
||||
#include "Throttle.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "configuration.h"
|
||||
#include "main.h"
|
||||
#include "time.h"
|
||||
|
||||
|
|
|
|||
|
|
@ -1030,7 +1030,7 @@ void menuHandler::messageViewModeMenu()
|
|||
name = sanitizeString(node->long_name).substr(0, 15);
|
||||
else {
|
||||
char buf[20];
|
||||
snprintf(buf, sizeof(buf), "Node %08X", peer);
|
||||
snprintf(buf, sizeof(buf), "Node !%08x", (unsigned int)peer);
|
||||
name = buf;
|
||||
}
|
||||
labels.push_back("@" + name);
|
||||
|
|
@ -1651,7 +1651,7 @@ void menuHandler::manageNodeMenu()
|
|||
title += sanitizeString(node->long_name).substr(0, 15);
|
||||
} else {
|
||||
char buf[20];
|
||||
snprintf(buf, sizeof(buf), "%08X", (unsigned int)node->num);
|
||||
snprintf(buf, sizeof(buf), "!%08x", (unsigned int)node->num);
|
||||
title += buf;
|
||||
}
|
||||
bannerOptions.message = title.c_str();
|
||||
|
|
@ -1671,10 +1671,10 @@ void menuHandler::manageNodeMenu()
|
|||
return;
|
||||
}
|
||||
if (nodeInfoLiteIsFavorite(n)) {
|
||||
LOG_INFO("Removing node %08X from favorites", menuHandler::pickedNodeNum);
|
||||
LOG_INFO("Removing node 0x%08x from favorites", menuHandler::pickedNodeNum);
|
||||
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
|
||||
} else {
|
||||
LOG_INFO("Adding node %08X to favorites", menuHandler::pickedNodeNum);
|
||||
LOG_INFO("Adding node 0x%08x to favorites", menuHandler::pickedNodeNum);
|
||||
// set_favorite() already logs PROTECTED_CAP_WARN_FMT on a cap refusal; don't double-log here.
|
||||
nodeDB->set_favorite(true, menuHandler::pickedNodeNum);
|
||||
}
|
||||
|
|
@ -1683,22 +1683,15 @@ void menuHandler::manageNodeMenu()
|
|||
}
|
||||
|
||||
if (selected == Mute) {
|
||||
auto n = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool wasMuted = nodeInfoLiteIsMuted(n);
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
|
||||
LOG_INFO(wasMuted ? "Unmuted node %08X" : "Muted node %08X", menuHandler::pickedNodeNum);
|
||||
nodeDB->notifyObservers(true);
|
||||
nodeDB->saveToDisk();
|
||||
// No lookup or null check here: toggleNodeMuted() resolves the node itself and returns
|
||||
// without writing if it is unknown.
|
||||
menuHandler::toggleNodeMuted(menuHandler::pickedNodeNum);
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected == TraceRoute) {
|
||||
LOG_INFO("Starting traceroute to %08X", menuHandler::pickedNodeNum);
|
||||
LOG_INFO("Starting traceroute to 0x%08x", menuHandler::pickedNodeNum);
|
||||
if (traceRouteModule) {
|
||||
traceRouteModule->startTraceRoute(menuHandler::pickedNodeNum);
|
||||
}
|
||||
|
|
@ -1706,7 +1699,7 @@ void menuHandler::manageNodeMenu()
|
|||
}
|
||||
|
||||
if (selected == KeyVerification) {
|
||||
LOG_INFO("Initiating key verification with %08X", menuHandler::pickedNodeNum);
|
||||
LOG_INFO("Initiating key verification with 0x%08x", menuHandler::pickedNodeNum);
|
||||
if (keyVerificationModule) {
|
||||
keyVerificationModule->sendInitialRequest(menuHandler::pickedNodeNum);
|
||||
}
|
||||
|
|
@ -1722,10 +1715,10 @@ void menuHandler::manageNodeMenu()
|
|||
bool changed = false;
|
||||
if (nodeInfoLiteIsIgnored(n)) {
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
|
||||
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
|
||||
LOG_INFO("Unignoring node 0x%08x", menuHandler::pickedNodeNum);
|
||||
changed = true;
|
||||
} else if (nodeDB->setProtectedFlag(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
|
||||
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
|
||||
LOG_INFO("Ignoring node 0x%08x", menuHandler::pickedNodeNum);
|
||||
changed = true;
|
||||
} else {
|
||||
LOG_WARN(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", menuHandler::pickedNodeNum, MAX_NUM_NODES - 2);
|
||||
|
|
@ -3024,6 +3017,21 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
|||
menuQueue = MenuNone;
|
||||
}
|
||||
|
||||
// Flips the mute bit on a node and persists. Returns without writing if the node is unknown, so a
|
||||
// stale pickedNodeNum can't cause a pointless flash write.
|
||||
void menuHandler::toggleNodeMuted(uint32_t nodeNum)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = nodeDB->getMeshNode(nodeNum);
|
||||
if (!n)
|
||||
return;
|
||||
|
||||
const bool wasMuted = nodeInfoLiteIsMuted(n);
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
|
||||
LOG_INFO(wasMuted ? "Unmuted node 0x%08x" : "Muted node 0x%08x", nodeNum);
|
||||
nodeDB->notifyObservers(true);
|
||||
nodeDB->saveToDisk();
|
||||
}
|
||||
|
||||
void menuHandler::saveUIConfig()
|
||||
{
|
||||
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,10 @@ class menuHandler
|
|||
static void hamModeConfirmMenu();
|
||||
static void licensedToNormalConfirmMenu();
|
||||
|
||||
// Lifted out of its banner-callback lambda so it is reachable without a Screen. The lambda only
|
||||
// ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable.
|
||||
static void toggleNodeMuted(uint32_t nodeNum); // uint32_t, matching pickedNodeNum above
|
||||
|
||||
private:
|
||||
static void saveUIConfig();
|
||||
static void keyVerificationInitMenu();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@
|
|||
#include "PortduinoGlue.h"
|
||||
#endif
|
||||
|
||||
/// Decode-stream ceiling for a `nodes.proto` written by *other* firmware - a migration allowance,
|
||||
/// **not this build's node cap**. That is `MAX_NUM_NODES`, which on portduino is a runtime value
|
||||
/// (`portduino_config.MaxNodes`, default 200) rather than a compile-time constant. 250 is the
|
||||
/// largest hot cap any shipped firmware has used (ESP32-S3 top flash tier), so a file from any of
|
||||
/// them still decodes here; the excess is trimmed after load.
|
||||
static constexpr size_t NODEDB_MIGRATION_LOAD_CEILING = 250;
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_PKI)
|
||||
// E3B0C442 is the blank hash
|
||||
static const uint8_t LOW_ENTROPY_HASHES[][32] = {
|
||||
|
|
@ -518,10 +525,12 @@ class NodeDB
|
|||
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
|
||||
// Decode-stream size ceiling only - no buffer this big is allocated (load
|
||||
// streams from the file). Sized for the largest file any prior firmware
|
||||
// could write (250-node ESP32-S3, satellites uncapped) so capacity
|
||||
// downgrades / peer backups still decode; excess is trimmed after load.
|
||||
// could write, so capacity downgrades / peer backups still decode; excess
|
||||
// is trimmed after load. See NODEDB_MIGRATION_LOAD_CEILING above - it is a
|
||||
// migration allowance, not this build's cap.
|
||||
// (not constexpr: portduino resolves MAX_NUM_NODES from runtime config)
|
||||
const size_t loadCeiling = ((size_t)MAX_NUM_NODES > 250) ? (size_t)MAX_NUM_NODES : 250;
|
||||
const size_t loadCeiling =
|
||||
((size_t)MAX_NUM_NODES > NODEDB_MIGRATION_LOAD_CEILING) ? (size_t)MAX_NUM_NODES : NODEDB_MIGRATION_LOAD_CEILING;
|
||||
return nodeDatabaseSize + (loadCeiling * meshtastic_NodeInfoLite_size) +
|
||||
(loadCeiling * meshtastic_NodePositionEntry_size) + (loadCeiling * meshtastic_NodeTelemetryEntry_size) +
|
||||
(loadCeiling * meshtastic_NodeEnvironmentEntry_size) + (loadCeiling * meshtastic_NodeStatusEntry_size);
|
||||
|
|
|
|||
|
|
@ -96,9 +96,10 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd
|
|||
r.rxTimeMsec = 1;
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: @start s=%08x id=%08x / to=%08x nh=%02x rn=%02x / wUpd=%s / wasFb?%d wWNH?%d",
|
||||
r.sender, r.id, p->to, p->next_hop, p->relay_node, withUpdate ? "YES" : "NO", wasFallback ? *wasFallback : -1,
|
||||
weWereNextHop ? *weWereNextHop : -1);
|
||||
LOG_DEBUG(
|
||||
"Packet History - Was Seen Recently: @start s=0x%08x id=0x%08x / to=0x%08x nh=%02x rn=%02x / wUpd=%s / wasFb?%d wWNH?%d",
|
||||
r.sender, r.id, p->to, p->next_hop, p->relay_node, withUpdate ? "YES" : "NO", wasFallback ? *wasFallback : -1,
|
||||
weWereNextHop ? *weWereNextHop : -1);
|
||||
#endif
|
||||
|
||||
PacketRecord *found = find(r.sender, r.id); // Find the packet record in the recentPackets array
|
||||
|
|
@ -125,14 +126,14 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd
|
|||
found->next_hop,
|
||||
*found)) { // If we were not the next hop and the next hop is not us, and we are not relaying this packet
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: f=%08x id=%08x nh=%02x rn=%02x oID=%02x, wasFbk=%d-set TRUE",
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: f=0x%08x id=0x%08x nh=%02x rn=%02x oID=%02x, wasFbk=%d-set TRUE",
|
||||
p->from, p->id, p->next_hop, p->relay_node, ourRelayID, wasFallback ? *wasFallback : -1);
|
||||
#endif
|
||||
*wasFallback = true;
|
||||
} else {
|
||||
// debug log only
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: f=%08x id=%08x nh=%02x rn=%02x oID=%02x, wasFbk=%d-no change",
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: f=0x%08x id=0x%08x nh=%02x rn=%02x oID=%02x, wasFbk=%d-no change",
|
||||
p->from, p->id, p->next_hop, p->relay_node, ourRelayID, wasFallback ? *wasFallback : -1);
|
||||
#endif
|
||||
}
|
||||
|
|
@ -142,7 +143,7 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd
|
|||
if (weWereNextHop) {
|
||||
*weWereNextHop = (found->next_hop == ourRelayID);
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: f=%08x id=%08x nh=%02x rn=%02x foundnh=%02x oID=%02x -> wWNH=%s",
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: f=0x%08x id=0x%08x nh=%02x rn=%02x foundnh=%02x oID=%02x -> wWNH=%s",
|
||||
p->from, p->id, p->next_hop, p->relay_node, found->next_hop, ourRelayID, (*weWereNextHop) ? "YES" : "NO");
|
||||
#endif
|
||||
}
|
||||
|
|
@ -151,7 +152,7 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd
|
|||
if (withUpdate) {
|
||||
if (found != NULL) {
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: s=%08x id=%08x nh=%02x rby=%02x %02x %02x age=%d wUpd BEFORE",
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: s=0x%08x id=0x%08x nh=%02x rby=%02x %02x %02x age=%d wUpd BEFORE",
|
||||
found->sender, found->id, found->next_hop, found->relayed_by[0], found->relayed_by[1], found->relayed_by[2],
|
||||
millis() - found->rxTimeMsec);
|
||||
#endif
|
||||
|
|
@ -192,15 +193,15 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd
|
|||
}
|
||||
r.next_hop = found->next_hop; // keep the original next_hop (such that we check whether we were originally asked)
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: s=%08x id=%08x nh=%02x rby=%02x %02x %02x age=%d wUpd AFTER", r.sender,
|
||||
r.id, r.next_hop, r.relayed_by[0], r.relayed_by[1], r.relayed_by[2], millis() - r.rxTimeMsec);
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: s=0x%08x id=0x%08x nh=%02x rby=%02x %02x %02x age=%d wUpd AFTER",
|
||||
r.sender, r.id, r.next_hop, r.relayed_by[0], r.relayed_by[1], r.relayed_by[2], millis() - r.rxTimeMsec);
|
||||
#endif
|
||||
// TODO: have direct *found entry - can modify directly without local copy _vs_ not convolute the code by this
|
||||
}
|
||||
insert(r); // Insert or update the packet record in the history
|
||||
}
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: @exit s=%08x id=%08x (to=%08x) relby=%02x %02x %02x nxthop=%02x rxT=%d "
|
||||
LOG_DEBUG("Packet History - Was Seen Recently: @exit s=0x%08x id=0x%08x (to=0x%08x) relby=%02x %02x %02x nxthop=%02x rxT=%d "
|
||||
"found?%s seenRecently?%s wUpd?%s",
|
||||
r.sender, r.id, p->to, r.relayed_by[0], r.relayed_by[1], r.relayed_by[2], r.next_hop, r.rxTimeMsec,
|
||||
found ? "YES" : "NO ", seenRecently ? "YES" : "NO ", withUpdate ? "YES" : "NO ");
|
||||
|
|
@ -286,7 +287,7 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
|
|||
{
|
||||
if (sender == 0 || id == 0) {
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - find: s=%08x id=%08x sender/id=0->NOT FOUND", sender, id);
|
||||
LOG_DEBUG("Packet History - find: s=0x%08x id=0x%08x sender/id=0->NOT FOUND", sender, id);
|
||||
#endif
|
||||
return NULL;
|
||||
}
|
||||
|
|
@ -301,7 +302,7 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
|
|||
uint16_t idx = hashIndex[bucket];
|
||||
if (idx < recentPacketsCapacity && recentPackets[idx].id == id && recentPackets[idx].sender == sender) {
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d",
|
||||
LOG_DEBUG("Packet History - find: s=0x%08x id=0x%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d",
|
||||
recentPackets[idx].sender, recentPackets[idx].id, recentPackets[idx].next_hop,
|
||||
recentPackets[idx].relayed_by[0], recentPackets[idx].relayed_by[1], recentPackets[idx].relayed_by[2],
|
||||
millis() - (recentPackets[idx].rxTimeMsec), idx, recentPacketsCapacity);
|
||||
|
|
@ -311,7 +312,7 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
|
|||
bucket = (bucket + 1) & hashMask;
|
||||
}
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - find: s=%08x id=%08x NOT FOUND", sender, id);
|
||||
LOG_DEBUG("Packet History - find: s=0x%08x id=0x%08x NOT FOUND", sender, id);
|
||||
#endif
|
||||
return NULL;
|
||||
}
|
||||
|
|
@ -356,9 +357,9 @@ void PacketHistory::insert(const PacketRecord &r)
|
|||
it = (base + recentPacketsCapacity);
|
||||
} else {
|
||||
if (it->rxTimeMsec == 0) {
|
||||
LOG_WARN(
|
||||
"Packet History - insert: Found packet s=%08x id=%08x with rxTimeMsec = 0, slot %d/%d. Should never happen!",
|
||||
it->sender, it->id, it - base, recentPacketsCapacity);
|
||||
LOG_WARN("Packet History - insert: Found packet s=0x%08x id=0x%08x with rxTimeMsec = 0, slot %d/%d. Should never "
|
||||
"happen!",
|
||||
it->sender, it->id, it - base, recentPacketsCapacity);
|
||||
}
|
||||
if ((now_millis - it->rxTimeMsec) > OldtrxTimeMsec) { // 49.7 days rollover friendly
|
||||
OldtrxTimeMsec = now_millis - it->rxTimeMsec;
|
||||
|
|
@ -416,7 +417,7 @@ void PacketHistory::insert(const PacketRecord &r)
|
|||
#endif
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE", tu - base,
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=0x%08x id=0x%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE", tu - base,
|
||||
recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2],
|
||||
tu->rxTimeMsec);
|
||||
#endif
|
||||
|
|
@ -445,7 +446,7 @@ void PacketHistory::insert(const PacketRecord &r)
|
|||
#endif
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER", tu - base,
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=0x%08x id=0x%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER", tu - base,
|
||||
recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2],
|
||||
tu->rxTimeMsec);
|
||||
#endif
|
||||
|
|
@ -462,7 +463,7 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N
|
|||
|
||||
if (relayer == 0) {
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - was relayer: s=%08x id=%08x / rl=%02x=zero. NO", sender, id, relayer);
|
||||
LOG_DEBUG("Packet History - was relayer: s=0x%08x id=0x%08x / rl=%02x=zero. NO", sender, id, relayer);
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
|
@ -471,13 +472,13 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N
|
|||
|
||||
if (found == NULL) {
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - was relayer: s=%08x id=%08x / rl=%02x / PR not found. NO", sender, id, relayer);
|
||||
LOG_DEBUG("Packet History - was relayer: s=0x%08x id=0x%08x / rl=%02x / PR not found. NO", sender, id, relayer);
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY >= 2
|
||||
LOG_DEBUG("Packet History - was relayer: s=%08x id=%08x nh=%02x age=%d rls=%02x %02x %02x InHistory,check:%02x",
|
||||
LOG_DEBUG("Packet History - was relayer: s=0x%08x id=0x%08x nh=%02x age=%d rls=%02x %02x %02x InHistory,check:%02x",
|
||||
found->sender, found->id, found->next_hop, millis() - found->rxTimeMsec, found->relayed_by[0], found->relayed_by[1],
|
||||
found->relayed_by[2], relayer);
|
||||
#endif
|
||||
|
|
@ -508,8 +509,8 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, boo
|
|||
}
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - was rel.PR.: s=%08x id=%08x rls=%02x %02x %02x / rl=%02x? NO", r.sender, r.id, r.relayed_by[0],
|
||||
r.relayed_by[1], r.relayed_by[2], relayer);
|
||||
LOG_DEBUG("Packet History - was rel.PR.: s=0x%08x id=0x%08x rls=%02x %02x %02x / rl=%02x? NO", r.sender, r.id,
|
||||
r.relayed_by[0], r.relayed_by[1], r.relayed_by[2], relayer);
|
||||
#endif
|
||||
|
||||
return found;
|
||||
|
|
@ -551,13 +552,13 @@ void PacketHistory::removeRelayer(const uint8_t relayer, const uint32_t id, cons
|
|||
PacketRecord *found = find(sender, id);
|
||||
if (found == NULL) {
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - remove Relayer s=%08x id=%08x (rl=%02x) NOT FOUND", sender, id, relayer);
|
||||
LOG_DEBUG("Packet History - remove Relayer s=0x%08x id=0x%08x (rl=%02x) NOT FOUND", sender, id, relayer);
|
||||
#endif
|
||||
return; // Nothing to remove
|
||||
}
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - remove Relayer s=%08x id=%08x rby=%02x %02x %02x, rl:%02x BEFORE", found->sender, found->id,
|
||||
LOG_DEBUG("Packet History - remove Relayer s=0x%08x id=0x%08x rby=%02x %02x %02x, rl:%02x BEFORE", found->sender, found->id,
|
||||
found->relayed_by[0], found->relayed_by[1], found->relayed_by[2], relayer);
|
||||
#endif
|
||||
|
||||
|
|
@ -577,7 +578,7 @@ void PacketHistory::removeRelayer(const uint8_t relayer, const uint32_t id, cons
|
|||
}
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - remove Relayer s=%08x id=%08x rby=%02x %02x %02x rl:%02x AFTER - removed?%d", found->sender,
|
||||
LOG_DEBUG("Packet History - remove Relayer s=0x%08x id=0x%08x rby=%02x %02x %02x rl:%02x AFTER - removed?%d", found->sender,
|
||||
found->id, found->relayed_by[0], found->relayed_by[1], found->relayed_by[2], relayer, i != j);
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
#include "StreamAPI.h"
|
||||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "PowerFSM.h"
|
||||
#include "StreamAPI.h"
|
||||
#include "Throttle.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "configuration.h"
|
||||
#include "gps/RTC.h"
|
||||
|
||||
#define START1 0x94
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
#ifdef USE_PACKET_API
|
||||
|
||||
#include "api/PacketAPI.h"
|
||||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "MeshService.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RadioInterface.h"
|
||||
#include "api/PacketAPI.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "ServerAPI.h"
|
||||
#include "Throttle.h"
|
||||
#include "configuration.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@
|
|||
#include "mesh/generated/meshtastic/localonly.pb.h"
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
// Portduino resolves MAX_NUM_NODES and MAX_RX_TOPHONE at runtime from variant.h. A TU reaching this
|
||||
// header without configuration.h (the vendored device-ui sources do) would silently get the
|
||||
// compile-time defaults below, so pull it in ahead of every one of them, not just MAX_NUM_NODES.
|
||||
#include "configuration.h"
|
||||
#endif
|
||||
|
||||
// this file defines constants which come from mesh.options
|
||||
//
|
||||
// RAM-shaped cache tiers key off MESHTASTIC_MEM_CLASS (memory/MemClass.h) so
|
||||
|
|
@ -112,7 +119,9 @@ static inline int get_max_num_nodes()
|
|||
}
|
||||
#define MAX_NUM_NODES get_max_num_nodes()
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
#define MAX_NUM_NODES 250 // native host: no flash/RAM constraint; match the ESP32-S3 top tier
|
||||
// Unreachable: the ARCH_PORTDUINO include at the top of this header defines it from variant.h.
|
||||
// Reaching here means that stopped working - still refuse to invent a divergent compile-time cap.
|
||||
#error "ARCH_PORTDUINO: configuration.h did not define MAX_NUM_NODES - check variants/native/portduino/variant.h"
|
||||
#else
|
||||
#define MAX_NUM_NODES 120 // nRF52840 and generic ESP32 (inc. ESP32C3 etc.)
|
||||
#endif // platform
|
||||
|
|
|
|||
|
|
@ -51,9 +51,13 @@ mail: marchammermann@googlemail.com
|
|||
// translation unit only compiles when the headers are present.
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#if __has_include(<ulfius.h>)
|
||||
#include "PiWebServer.h"
|
||||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "NodeDB.h"
|
||||
#include "PhoneAPI.h"
|
||||
#include "PiWebServer.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RadioLibInterface.h"
|
||||
#include "airtime.h"
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
|
|||
suppressReplyForCurrentRequest = false;
|
||||
|
||||
if (mp.from == nodeDB->getNodeNum()) {
|
||||
LOG_WARN("Ignoring packet supposed to be from our own node: %08x", mp.from);
|
||||
LOG_WARN("Ignoring packet supposed to be from our own node: 0x%08x", mp.from);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
|
|||
}
|
||||
|
||||
// Log packet size and data fields
|
||||
LOG_DEBUG("POSITION node=%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d "
|
||||
LOG_DEBUG("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d "
|
||||
"time=%d",
|
||||
getFrom(&mp), mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae,
|
||||
p.altitude_geoidal_separation, p.PDOP, p.HDOP, p.VDOP, p.sats_in_view, p.fix_quality, p.fix_type, p.timestamp,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "ServiceEnvelope.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include <pb_decode.h>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
#include "MeshtasticOTA.h"
|
||||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "MeshtasticOTA.h"
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <Preferences.h>
|
||||
#include <esp_ota_ops.h>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@
|
|||
namespace
|
||||
{
|
||||
|
||||
// Largest General.MaxNodes we accept - artificial, not derived: nothing fails at 16001. Catches a
|
||||
// typo that would otherwise size the node DB into a boot-time allocation failure. Sits under the
|
||||
// 16384 (128 x 128) where HopScalingModule saturates and drops nodes. Raise it if a host needs more.
|
||||
constexpr int MAX_NODES_SANITY_CEILING = 16000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -964,6 +969,13 @@ void checkMergedConfig(const PathIndex &paths, std::vector<Finding> &findings)
|
|||
findings.push_back({kError, merged, 0,
|
||||
"General.MaxNodes is " + std::to_string(portduino_config.MaxNodes) +
|
||||
", which leaves no room for even this node's own entry"});
|
||||
// Upper bound too: MAX_NUM_NODES scales the node DB and the nodes.proto decode ceiling
|
||||
// (NodeDB::getMaxNodesAllocatedSize()), so a typo'd value is a boot-time memory failure with no
|
||||
// obvious cause. A sanity bound, not a capability limit - raise it if a host genuinely needs more.
|
||||
else if (portduino_config.MaxNodes > MAX_NODES_SANITY_CEILING)
|
||||
findings.push_back({kError, merged, 0,
|
||||
"General.MaxNodes is " + std::to_string(portduino_config.MaxNodes) + ", above the " +
|
||||
std::to_string(MAX_NODES_SANITY_CEILING) + " sanity ceiling"});
|
||||
|
||||
#if !defined(HAS_HUB75_NATIVE)
|
||||
// A build-time gap rather than a config error: the same file is valid on a
|
||||
|
|
|
|||
106
test/README.md
106
test/README.md
|
|
@ -4,7 +4,7 @@ This directory contains C++ unit tests that run on the host machine via Platform
|
|||
|
||||
## Running Tests
|
||||
|
||||
**Preferred: use `bin/run-tests.sh`** - it runs the `coverage` env (ASan/LSan sanitizers), cross-checks the number of suites that actually ran, and emits an unambiguous RED/AMBER/GREEN verdict:
|
||||
**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:
|
||||
|
||||
```bash
|
||||
./bin/run-tests.sh # all suites
|
||||
|
|
@ -12,7 +12,24 @@ This directory contains C++ unit tests that run on the host machine via Platform
|
|||
./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.
|
||||
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.
|
||||
|
||||
```bash
|
||||
./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.
|
||||
|
||||
|
|
@ -165,7 +182,7 @@ void setup()
|
|||
printf("\n=== Example group ===\n"); // header line to help find tests
|
||||
|
||||
RUN_TEST(test_example);
|
||||
exit(UNITY_END()); // exit() required - Unity runner expects it
|
||||
exit(UNITY_END()); // REQUIRED - a bare UNITY_END() leaves the process running
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
|
@ -187,7 +204,19 @@ void loop() {}
|
|||
#endif
|
||||
```
|
||||
|
||||
### 3. Feature Guard
|
||||
### 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.
|
||||
|
||||
|
|
@ -292,11 +321,36 @@ void test_something() {
|
|||
|
||||
## Pitfalls and How to Avoid Them
|
||||
|
||||
### 1. Persisted Filesystem State Leaks Between Tests
|
||||
### 1. Persisted Filesystem State
|
||||
|
||||
Modules that save state to `/prefs/*.bin` will have that state loaded by the next test's constructor via `loadState()`. This causes values from one test (e.g. rolling averages from a megamesh scenario) to bleed into unrelated tests.
|
||||
**You are handed a clean sandbox. Declare what you write.**
|
||||
|
||||
**Fix:** Delete state files at the start of `setUp()`:
|
||||
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:
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```cpp
|
||||
void setUp(void) {
|
||||
|
|
@ -307,13 +361,32 @@ void setUp(void) {
|
|||
}
|
||||
```
|
||||
|
||||
### 2. File-Scope Mutable Globals Persist Across Tests
|
||||
### 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:
|
||||
|
||||
```cpp
|
||||
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()`.
|
||||
|
||||
### 3. Randomness Breaks Determinism
|
||||
### 4. Randomness Breaks Determinism
|
||||
|
||||
If the module uses `rand()` for jitter or similar, test results become non-reproducible.
|
||||
|
||||
|
|
@ -330,7 +403,7 @@ YourModule::setJitter(false);
|
|||
YourModule::setJitter(true);
|
||||
```
|
||||
|
||||
### 4. Time-Dependent Logic Produces Zeros
|
||||
### 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.
|
||||
|
||||
|
|
@ -344,13 +417,13 @@ void setWindowStartMs(uint32_t ms) { windowStartMs = ms; }
|
|||
shim.setWindowStartMs(millis() - 3600000UL); // pretend 1 hour elapsed
|
||||
```
|
||||
|
||||
### 5. Capacity Limits Cause Cascading Failures
|
||||
### 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.
|
||||
|
||||
### 6. Granting test access to private/protected members
|
||||
### 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**:
|
||||
|
||||
|
|
@ -370,7 +443,8 @@ PlatformIO defines `PIO_UNIT_TESTING` during `pio test` builds. Several producti
|
|||
- [ ] Create and clear MockNodeDB (if needed)
|
||||
- [ ] Zero global configs: `config`, `moduleConfig`, `myNodeInfo`
|
||||
- [ ] Set `nodeDB = mockNodeDB`
|
||||
- [ ] Delete persisted state files (`FSCom.remove(...)`)
|
||||
- [ ] 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
|
||||
|
|
@ -402,9 +476,15 @@ 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 |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// First, in its own block so the include sorter keeps it there: configuration.h supplies the
|
||||
// variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime).
|
||||
#include "configuration.h"
|
||||
|
||||
#include "SerialConsole.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "gps/RTC.h"
|
||||
|
|
@ -11,6 +15,19 @@
|
|||
#include <thread>
|
||||
#endif
|
||||
|
||||
// The state checkpoint needs a POSIX directory walk, and only the host builds run these suites.
|
||||
// Note ARDUINO *is* defined on portduino, so it is not the right guard here.
|
||||
#if ARCH_PORTDUINO
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <dirent.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
void initializeTestEnvironment()
|
||||
{
|
||||
concurrency::hasBeenSetup = true;
|
||||
|
|
@ -22,6 +39,10 @@ void initializeTestEnvironment()
|
|||
perhapsSetRTC(RTCQualityNTP, &tv);
|
||||
#endif
|
||||
concurrency::OSThread::setup();
|
||||
|
||||
// Baseline the sandbox before the first RUN_TEST, so writes made during suite setup are not
|
||||
// charged to whichever test happens to run first.
|
||||
testStateCheckpoint(nullptr, nullptr);
|
||||
}
|
||||
|
||||
void testDelay(unsigned long ms)
|
||||
|
|
@ -32,3 +53,132 @@ void testDelay(unsigned long ms)
|
|||
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !ARCH_PORTDUINO
|
||||
|
||||
void testStateCheckpoint(const char *, const char *) {}
|
||||
|
||||
#else
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/// Content fingerprint, used only to answer "did this file change?". FNV-1a rather than a real
|
||||
/// digest because the answer is a boolean and the files are a few KB of protobuf; nothing here
|
||||
/// records a hash as an expected value, which is what would make this a snapshot test.
|
||||
uint64_t fileFingerprint(const std::string &path)
|
||||
{
|
||||
FILE *f = fopen(path.c_str(), "rb");
|
||||
if (!f)
|
||||
return 0;
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
unsigned char buf[4096];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
h ^= buf[i];
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return h;
|
||||
}
|
||||
|
||||
void walk(const std::string &root, const std::string &rel, std::map<std::string, uint64_t> &out)
|
||||
{
|
||||
const std::string dirPath = rel.empty() ? root : root + "/" + rel;
|
||||
DIR *d = opendir(dirPath.c_str());
|
||||
if (!d)
|
||||
return;
|
||||
while (struct dirent *e = readdir(d)) {
|
||||
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
|
||||
continue;
|
||||
const std::string childRel = rel.empty() ? std::string(e->d_name) : rel + "/" + e->d_name;
|
||||
const std::string childPath = root + "/" + childRel;
|
||||
struct stat st;
|
||||
if (lstat(childPath.c_str(), &st) != 0)
|
||||
continue;
|
||||
if (S_ISDIR(st.st_mode))
|
||||
walk(root, childRel, out);
|
||||
else if (S_ISREG(st.st_mode))
|
||||
out[childRel] = fileFingerprint(childPath);
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
/// "test/test_admin_radio/test_main.cpp" -> "test_admin_radio". The suite name is not otherwise
|
||||
/// available to a test program - PlatformIO passes it to the *build*, not to the run.
|
||||
std::string suiteFromPath(const char *sourceFile)
|
||||
{
|
||||
if (!sourceFile)
|
||||
return "";
|
||||
std::string p(sourceFile);
|
||||
const size_t lastSlash = p.find_last_of('/');
|
||||
if (lastSlash == std::string::npos)
|
||||
return "";
|
||||
p.erase(lastSlash);
|
||||
const size_t prevSlash = p.find_last_of('/');
|
||||
return prevSlash == std::string::npos ? p : p.substr(prevSlash + 1);
|
||||
}
|
||||
|
||||
struct StateWatch {
|
||||
bool resolved = false;
|
||||
bool active = false;
|
||||
std::string root;
|
||||
std::string report;
|
||||
std::map<std::string, uint64_t> previous;
|
||||
};
|
||||
|
||||
StateWatch &watch()
|
||||
{
|
||||
static StateWatch w;
|
||||
return w;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void testStateCheckpoint(const char *testName, const char *sourceFile)
|
||||
{
|
||||
StateWatch &w = watch();
|
||||
|
||||
if (!w.resolved) {
|
||||
w.resolved = true;
|
||||
const char *report = getenv("MESHTASTIC_TEST_STATE_REPORT");
|
||||
const char *home = getenv("HOME");
|
||||
// No report path means nobody asked: a bare `pio test` behaves exactly as before.
|
||||
w.active = report && *report && home && *home;
|
||||
if (w.active) {
|
||||
w.report = report;
|
||||
w.root = home;
|
||||
}
|
||||
}
|
||||
if (!w.active)
|
||||
return;
|
||||
|
||||
std::map<std::string, uint64_t> current;
|
||||
walk(w.root, "", current);
|
||||
|
||||
// The priming call from initializeTestEnvironment() has no test to attribute to.
|
||||
if (testName) {
|
||||
const std::string suite = suiteFromPath(sourceFile);
|
||||
FILE *out = fopen(w.report.c_str(), "a");
|
||||
if (out) {
|
||||
for (const auto &entry : current) {
|
||||
auto prior = w.previous.find(entry.first);
|
||||
if (prior == w.previous.end())
|
||||
fprintf(out, "%s\t%s\tadded\t%s\n", suite.c_str(), testName, entry.first.c_str());
|
||||
else if (prior->second != entry.second)
|
||||
fprintf(out, "%s\t%s\tmodified\t%s\n", suite.c_str(), testName, entry.first.c_str());
|
||||
}
|
||||
for (const auto &entry : w.previous) {
|
||||
if (current.find(entry.first) == current.end())
|
||||
fprintf(out, "%s\t%s\tremoved\t%s\n", suite.c_str(), testName, entry.first.c_str());
|
||||
}
|
||||
fclose(out);
|
||||
}
|
||||
}
|
||||
|
||||
w.previous.swap(current);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,7 +1,32 @@
|
|||
#pragma once
|
||||
|
||||
#include <unity.h>
|
||||
|
||||
// Initialize testing environment.
|
||||
void initializeTestEnvironment();
|
||||
|
||||
// Portable delay for tests (Arduino or host).
|
||||
void testDelay(unsigned long ms);
|
||||
|
||||
// Record which files under the scratch $HOME changed since the previous checkpoint, attributing
|
||||
// each change to the test that made it.
|
||||
//
|
||||
// Enabled only when bin/pio-test-isolate.sh sets MESHTASTIC_TEST_STATE_REPORT; a bare `pio test`
|
||||
// and every on-device build see a no-op. This emits facts only - whether a given write is allowed
|
||||
// is decided by the harness against test/state-manifest.tsv, so the policy stays in one reviewable
|
||||
// place instead of being spread across 40-odd suites.
|
||||
void testStateCheckpoint(const char *testName, const char *sourceFile);
|
||||
|
||||
// Every RUN_TEST becomes a checkpoint. An unintended write has no matching assertion *by
|
||||
// definition* - nobody wrote a TEST_ASSERT for the nodes.proto write that broke test_admin_radio,
|
||||
// because nobody knew it happened - so attribution has to come from outside the test body.
|
||||
//
|
||||
// Unity's own RUN_TEST is #ifndef-guarded, but redefine unconditionally so it cannot matter whether
|
||||
// a suite includes <unity.h> before or after this header. The variadic form accepts Unity's
|
||||
// optional line argument; the call site's __LINE__ is what Unity reports either way.
|
||||
#undef RUN_TEST
|
||||
#define RUN_TEST(func, ...) \
|
||||
do { \
|
||||
UnityDefaultTestRun(func, #func, __LINE__); \
|
||||
testStateCheckpoint(#func, __FILE__); \
|
||||
} while (0)
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
43
|
||||
44
|
||||
|
|
|
|||
58
test/state-manifest.tsv
Normal file
58
test/state-manifest.tsv
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Shared-state manifest for the native test suites.
|
||||
#
|
||||
# Every suite runs inside its own scratch $HOME (bin/pio-test-isolate.sh), so leftovers cannot reach
|
||||
# the next suite. This file is not what makes that safe - it is what makes each suite's intent
|
||||
# reviewable. A suite listed here is declaring "I mutate persisted state on purpose, and here is
|
||||
# why"; a suite that is not listed is expected to leave the sandbox as it found it.
|
||||
#
|
||||
# The invariant behind the flags: mutation *inside* a suite is free, carrying state *across* a suite
|
||||
# boundary is never permitted. No flag grants cross-suite carry - a suite that needs another suite's
|
||||
# output needs an explicit fixture, not inheritance. That is the whole bug, and per-suite isolation
|
||||
# keeps it impossible by construction rather than by policy.
|
||||
#
|
||||
# Format - three tab-separated columns, the same shape as an allowlist entry:
|
||||
#
|
||||
# <suite> <flags> <reason>
|
||||
#
|
||||
# The reason column is mandatory and is reviewed on change. One central file rather than a file per
|
||||
# suite, so every opt-out is visible in one diffable list and attracts review pressure; per-suite
|
||||
# files hide growth. bin/run-tests.sh prints how many suites declare non-default handling, so the
|
||||
# number creeping upward is visible without anyone auditing this file.
|
||||
#
|
||||
# Flags (space separated; the default is no entry at all):
|
||||
#
|
||||
# writes=<a,b> files this suite mutates inside its sandbox. Matched against the path relative
|
||||
# to the scratch $HOME or just the basename, so `nodes.proto` is enough. A
|
||||
# declared write makes the change CLEAN instead of DIRTY - the list is the
|
||||
# documentation. A declared file that does NOT change is reported as MISSING,
|
||||
# which catches silently-broken persistence.
|
||||
# state=per-suite state persists across this suite's own test cases; the default is per-test.
|
||||
# Use for persistence round-trips, migration ladders, anything where test N must
|
||||
# observe test N-1's write. With this set, only the suite boundary is checked -
|
||||
# per-test checking would flag every test by design.
|
||||
#
|
||||
# state=per-suite is the one to watch. It is legitimate, and it is also the pattern that let
|
||||
# test_nodedb_blocked accumulate 198 protected nodes across its test cases. Requiring the flag makes
|
||||
# that an explicit, defended choice instead of an accident of setUp().
|
||||
#
|
||||
# To propose entries from a real run: ./bin/run-tests.sh --write-manifest prints the lines it would
|
||||
# add, for a human to paste and justify. It never applies them itself, and CI never applies them at
|
||||
# all - an auto-accepted baseline is the same rot as an auto-updated snapshot.
|
||||
#
|
||||
# suite flags reason
|
||||
test_admin_radio writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs per-test NodeDB fixture, and the admin handlers under test persist config, channels and node metadata
|
||||
test_admin_session_repro writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty
|
||||
test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs drives decode of fuzzed packets through the real NodeDB and message store
|
||||
test_hop_scaling writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB to hold the hop-distance fixtures
|
||||
test_mesh_beacon writes=module.proto exercises the beacon's module-config save path
|
||||
test_mesh_module writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat module framework tests construct a NodeDB
|
||||
test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB for node lookups in the MQTT paths
|
||||
test_nexthop_routing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto next-hop selection reads and updates the node DB
|
||||
test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat saturates the DB with MAX_NUM_NODES-2 favourited nodes to test the protected cap; a later test's removeNodeByNum() persists that state, and the cap test depends on the fill from the test before it
|
||||
test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat needs a NodeDB holding both peers' keys for the PKI encode/decode paths
|
||||
test_pki_admin_fallback writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto needs a NodeDB holding admin keys for the fallback paths
|
||||
test_stream_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto drives real PhoneAPI handshakes, which read and persist config and the node DB
|
||||
test_traceroute_nexthop writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto traceroute route selection reads the node DB
|
||||
test_traffic_management writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat constructs a NodeDB for the per-node rate-limit and dedup state
|
||||
test_transmit_history writes=transmit_history.dat persistence round-trip: what it asserts is that retransmission state survives a save/load
|
||||
test_warm_store writes=warm.dat persistence round-trip of the warm-tier snapshot, which is the tier's whole contract
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 48.
|
|
|
@ -19,6 +19,7 @@
|
|||
#include "NodeDB.h"
|
||||
#include "RadioInterface.h"
|
||||
#include "TestUtil.h"
|
||||
#include "graphics/draw/MenuHandler.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
|
|
@ -954,7 +955,6 @@ static void test_channelSpacingCalculation_placeholder()
|
|||
|
||||
// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
|
||||
static AdminModuleTestShim *testAdmin;
|
||||
static bool adminRadioGlobalsActive;
|
||||
static NodeDB *savedNodeDB;
|
||||
static NodeDB *replacementNodeDB;
|
||||
static NodeInfoModule *savedNodeInfoModule;
|
||||
|
|
@ -963,6 +963,9 @@ static meshtastic_User savedOwner;
|
|||
static meshtastic_LocalConfig savedConfig;
|
||||
static meshtastic_ChannelFile savedChannelFile;
|
||||
|
||||
// Called from setUp/tearDown for every test, not opted into by a handful. A shared NodeDB plus
|
||||
// unrestored config/owner/devicestate/channelFile means each test inherits whatever its
|
||||
// predecessors left, and the admin handlers under test write all four.
|
||||
static void replaceAdminRadioGlobals()
|
||||
{
|
||||
savedNodeDB = nodeDB;
|
||||
|
|
@ -973,13 +976,10 @@ static void replaceAdminRadioGlobals()
|
|||
savedChannelFile = channelFile;
|
||||
replacementNodeDB = new NodeDB();
|
||||
nodeDB = replacementNodeDB;
|
||||
adminRadioGlobalsActive = true;
|
||||
}
|
||||
|
||||
static void restoreAdminRadioGlobals()
|
||||
{
|
||||
if (!adminRadioGlobalsActive)
|
||||
return;
|
||||
nodeInfoModule = savedNodeInfoModule;
|
||||
nodeDB = savedNodeDB;
|
||||
delete replacementNodeDB;
|
||||
|
|
@ -989,7 +989,6 @@ static void restoreAdminRadioGlobals()
|
|||
config = savedConfig;
|
||||
channelFile = savedChannelFile;
|
||||
initRegion();
|
||||
adminRadioGlobalsActive = false;
|
||||
}
|
||||
|
||||
static void installEncryptedAndAdminChannels()
|
||||
|
|
@ -1024,7 +1023,6 @@ static void assertLicensedChannelsSanitized()
|
|||
|
||||
static void test_handleSetOwner_persistsLicensedChannelSanitation()
|
||||
{
|
||||
replaceAdminRadioGlobals();
|
||||
owner = meshtastic_User_init_zero;
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||
installEncryptedAndAdminChannels();
|
||||
|
|
@ -1100,7 +1098,6 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo
|
|||
|
||||
static void test_handleSetConfig_persistsLicensedFirstRegionIdentity()
|
||||
{
|
||||
replaceAdminRadioGlobals();
|
||||
owner = meshtastic_User_init_zero;
|
||||
owner.is_licensed = true;
|
||||
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
||||
|
|
@ -1722,6 +1719,138 @@ static void test_warn_license_transaction_coalescedToSingleMessage()
|
|||
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Node-DB admin metadata: favorite / ignore / mute
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// MeshService::reloadConfig() only re-derives the region and fires configChanged - which drives the
|
||||
// live SX126x/RadioInterface reconfigure - when saveWhat includes SEGMENT_CONFIG or
|
||||
// SEGMENT_CHANNELS. A pure node-DB metadata save must skip that reconfigure entirely. These watch
|
||||
// service->configChanged directly, so widening the saveWhat mask or reordering the check is caught
|
||||
// even though they run outside an edit transaction.
|
||||
//
|
||||
// Characterization: all three already hold on develop. They are worth pinning because that reload
|
||||
// is the path implicated in the WisMesh Tag favourite-node crash, and nothing asserted it.
|
||||
|
||||
// Counts configChanged.notifyObservers() calls - the only externally visible signal that
|
||||
// reloadConfig() took the radio-reconfigure branch.
|
||||
class ConfigChangedCounter : public Observer<void *>
|
||||
{
|
||||
public:
|
||||
int count = 0;
|
||||
|
||||
protected:
|
||||
int onNotify(void *arg) override
|
||||
{
|
||||
count++;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
static const NodeNum TEST_NODE_NUM = 0x12345678;
|
||||
|
||||
static void test_setFavoriteNode_skipsRadioReload_butPersists()
|
||||
{
|
||||
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
||||
ConfigChangedCounter counter;
|
||||
counter.observe(&service->configChanged);
|
||||
|
||||
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
||||
m.which_payload_variant = meshtastic_AdminMessage_set_favorite_node_tag;
|
||||
m.set_favorite_node = TEST_NODE_NUM;
|
||||
sendAdmin(m);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
||||
TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
||||
}
|
||||
|
||||
static void test_setIgnoredNode_skipsRadioReload_butPersists()
|
||||
{
|
||||
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
||||
ConfigChangedCounter counter;
|
||||
counter.observe(&service->configChanged);
|
||||
|
||||
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
||||
m.which_payload_variant = meshtastic_AdminMessage_set_ignored_node_tag;
|
||||
m.set_ignored_node = TEST_NODE_NUM;
|
||||
sendAdmin(m);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
||||
TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
||||
}
|
||||
|
||||
static void test_toggleMutedNode_skipsRadioReload_butPersists()
|
||||
{
|
||||
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
||||
ConfigChangedCounter counter;
|
||||
counter.observe(&service->configChanged);
|
||||
|
||||
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
||||
m.which_payload_variant = meshtastic_AdminMessage_toggle_muted_node_tag;
|
||||
m.toggle_muted_node = TEST_NODE_NUM;
|
||||
sendAdmin(m);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
||||
TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Node menu mute toggle (graphics::menuHandler::toggleNodeMuted)
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// Reachable only since the mute branch was lifted out of its banner-callback lambda; the lambda
|
||||
// runs via screen->showOverlayBanner(), so nothing in MenuHandler.cpp was testable before.
|
||||
|
||||
#if HAS_SCREEN
|
||||
static void test_toggleNodeMuted_flipsBitAndSkipsRadioReload()
|
||||
{
|
||||
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
||||
ConfigChangedCounter counter;
|
||||
counter.observe(&service->configChanged);
|
||||
|
||||
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
|
||||
TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
||||
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
||||
|
||||
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
|
||||
TEST_ASSERT_FALSE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
||||
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
||||
}
|
||||
|
||||
static void test_toggleNodeMuted_unknownNodeDoesNothing()
|
||||
{
|
||||
ConfigChangedCounter counter;
|
||||
counter.observe(&service->configChanged);
|
||||
|
||||
graphics::menuHandler::toggleNodeMuted(0xDEADBEEF); // never added to the DB
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
||||
TEST_ASSERT_NULL(nodeDB->getMeshNode(0xDEADBEEF));
|
||||
}
|
||||
|
||||
// CHARACTERIZATION OF A KNOWN DEFECT, not an endorsement. Flipping one NodeInfoLite bit currently
|
||||
// calls bare nodeDB->saveToDisk(), which rewrites all five segments. saveToDisk() is not virtual,
|
||||
// so the mask is observed through its effect: every prefs file reappears after being removed.
|
||||
//
|
||||
// A pending fix narrows this to SEGMENT_NODEDATABASE. When it lands, only nodes.proto should come
|
||||
// back and this assertion is EXPECTED to change - that diff is the point, so the improvement is
|
||||
// visible instead of silent.
|
||||
static void test_toggleNodeMuted_currentlyRewritesEverySegment()
|
||||
{
|
||||
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
||||
|
||||
const char *segmentFiles[] = {configFileName, moduleConfigFileName, deviceStateFileName, channelFileName,
|
||||
nodeDatabaseFileName};
|
||||
for (const char *f : segmentFiles)
|
||||
FSCom.remove(f);
|
||||
|
||||
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
|
||||
|
||||
for (const char *f : segmentFiles)
|
||||
TEST_ASSERT_TRUE_MESSAGE(FSCom.exists(f), f);
|
||||
}
|
||||
#endif // HAS_SCREEN
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test runner
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -1732,11 +1861,8 @@ void setUp(void)
|
|||
service = mockMeshService;
|
||||
testAdmin = new AdminModuleTestShim();
|
||||
capturedWarnings.clear();
|
||||
// Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB.
|
||||
// Create it once (kept reachable via the global, so no leak) for the warning tests; the
|
||||
// other tests in this suite set their own config/region state and are unaffected.
|
||||
if (!nodeDB)
|
||||
nodeDB = new NodeDB();
|
||||
// Every test gets its own NodeDB and its own copy of the globals the admin handlers write.
|
||||
replaceAdminRadioGlobals();
|
||||
}
|
||||
void tearDown(void)
|
||||
{
|
||||
|
|
@ -1864,6 +1990,18 @@ void setup()
|
|||
RUN_TEST(test_warn_license_noTransaction_emittedImmediately);
|
||||
RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage);
|
||||
|
||||
// Node-DB metadata saves must not reconfigure the radio
|
||||
RUN_TEST(test_setFavoriteNode_skipsRadioReload_butPersists);
|
||||
RUN_TEST(test_setIgnoredNode_skipsRadioReload_butPersists);
|
||||
RUN_TEST(test_toggleMutedNode_skipsRadioReload_butPersists);
|
||||
|
||||
#if HAS_SCREEN
|
||||
// Node menu mute toggle
|
||||
RUN_TEST(test_toggleNodeMuted_flipsBitAndSkipsRadioReload);
|
||||
RUN_TEST(test_toggleNodeMuted_unknownNodeDoesNothing);
|
||||
RUN_TEST(test_toggleNodeMuted_currentlyRewritesEverySegment);
|
||||
#endif
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,74 @@ void test_event_mode_caps_optimized_response()
|
|||
}
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getConfiguredOrDefaultMsScaled(..., TrafficType) - the region-throttle overload
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// This is the overload every telemetry and position module actually calls, and nothing covered it:
|
||||
// not the throttle multiply, not the <= 1 short-circuit, not the no-region guard, not the 64-bit
|
||||
// overflow clamp. Region throttles are real - EU_866 carries PROFILE_LITE with a x10 on both
|
||||
// position and telemetry - so a change here silently changes broadcast spacing in that region.
|
||||
//
|
||||
// Each test pins numOnlineNodes at or below the congestion threshold and uses a non-scaling role,
|
||||
// so the congestion coefficient is 1 and the only variable left is the throttle.
|
||||
|
||||
static const uint32_t kUnscaledNodes = 40; // at/below the threshold: congestion coefficient is 1
|
||||
|
||||
static void useRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; // routers never congestion-scale
|
||||
config.lora.region = region;
|
||||
initRegion();
|
||||
}
|
||||
|
||||
void test_trafficType_noRegion_returnsUnthrottled()
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
const RegionInfo *saved = myRegion;
|
||||
myRegion = nullptr;
|
||||
|
||||
const uint32_t base = Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes);
|
||||
TEST_ASSERT_EQUAL_UINT32(base, Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes, TrafficType::TELEMETRY));
|
||||
myRegion = saved;
|
||||
}
|
||||
|
||||
void test_trafficType_neutralThrottle_returnsUnthrottled()
|
||||
{
|
||||
// US carries PROFILE_STD, whose position and telemetry throttles are both 1 - the neutral
|
||||
// multiplier the implementation short-circuits on.
|
||||
useRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
||||
TEST_ASSERT_EQUAL_INT8(1, myRegion->profile->telemetryThrottle);
|
||||
|
||||
const uint32_t base = Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes);
|
||||
TEST_ASSERT_EQUAL_UINT32(base, Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes, TrafficType::TELEMETRY));
|
||||
TEST_ASSERT_EQUAL_UINT32(base, Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes, TrafficType::POSITION));
|
||||
}
|
||||
|
||||
void test_trafficType_regionThrottleMultiplies()
|
||||
{
|
||||
// EU_866 carries PROFILE_LITE: positionThrottle and telemetryThrottle are both 10.
|
||||
useRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
|
||||
const int8_t telemetryThrottle = myRegion->profile->telemetryThrottle;
|
||||
const int8_t positionThrottle = myRegion->profile->positionThrottle;
|
||||
TEST_ASSERT_GREATER_THAN_INT8(1, telemetryThrottle);
|
||||
|
||||
const uint32_t base = Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes);
|
||||
TEST_ASSERT_EQUAL_UINT32(base * telemetryThrottle,
|
||||
Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes, TrafficType::TELEMETRY));
|
||||
TEST_ASSERT_EQUAL_UINT32(base * positionThrottle,
|
||||
Default::getConfiguredOrDefaultMsScaled(0, 60u, kUnscaledNodes, TrafficType::POSITION));
|
||||
}
|
||||
|
||||
void test_trafficType_overflowSaturates()
|
||||
{
|
||||
// A day-long base times a x10 region throttle exceeds uint32 without the 64-bit guard.
|
||||
useRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
|
||||
|
||||
const uint32_t res = Default::getConfiguredOrDefaultMsScaled(0, 3 * ONE_DAY, kUnscaledNodes, TrafficType::TELEMETRY);
|
||||
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(INT32_MAX), res);
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
// Small delay to match other test mains
|
||||
|
|
@ -230,6 +298,10 @@ void setup()
|
|||
RUN_TEST(test_ms_result_is_int32_safe);
|
||||
RUN_TEST(test_scaled_overflow_saturates);
|
||||
RUN_TEST(test_configured_or_default_hop_limit);
|
||||
RUN_TEST(test_trafficType_noRegion_returnsUnthrottled);
|
||||
RUN_TEST(test_trafficType_neutralThrottle_returnsUnthrottled);
|
||||
RUN_TEST(test_trafficType_regionThrottleMultiplies);
|
||||
RUN_TEST(test_trafficType_overflowSaturates);
|
||||
#if USERPREFS_EVENT_MODE
|
||||
RUN_TEST(test_event_mode_caps_optimized_response);
|
||||
#endif
|
||||
|
|
|
|||
243
test/test_fscommon_getfiles/test_main.cpp
Normal file
243
test/test_fscommon_getfiles/test_main.cpp
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Tests for getFiles() - the bounded file-manifest walk in src/FSCommon.cpp that PhoneAPI's
|
||||
// STATE_SEND_FILEMANIFEST drives on every phone sync. Nothing else asserts its bounding behaviour:
|
||||
// the walk does run unasserted from test_stream_api's handshakes, but no test covers the cap, the
|
||||
// depth limit, the wasLimited paths, overlong-path rejection, or capacity release.
|
||||
//
|
||||
// These assert what the code does today. Today's code is already correct here - #10778 landed the
|
||||
// by-reference collectFiles(), the 64-entry cap, strlcpy bounds and the swap-idiom release - so all
|
||||
// of these describe present behaviour rather than a pending fix.
|
||||
#include "MeshTypes.h"
|
||||
#include "TestUtil.h"
|
||||
// FSCommon.h is what defines FSCom, so it has to be included before anything tests for it.
|
||||
#include "FSCommon.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <unity.h>
|
||||
#include <vector>
|
||||
|
||||
#ifdef FSCom
|
||||
|
||||
// The suite builds its tree through FSCom rather than host mkdir/fopen: PortduinoFS confines paths
|
||||
// to its mountpoint, so going through the same API is the only way the test stays agnostic about
|
||||
// where that mountpoint is.
|
||||
static const char *kRoot = "/test_getfiles";
|
||||
|
||||
static void makeFile(const char *path, size_t bytes)
|
||||
{
|
||||
File f = FSCom.open(path, FILE_O_WRITE);
|
||||
TEST_ASSERT_TRUE_MESSAGE(f, path);
|
||||
for (size_t i = 0; i < bytes; i++)
|
||||
f.write('x');
|
||||
f.close();
|
||||
}
|
||||
|
||||
static bool manifestContains(const std::vector<meshtastic_FileInfo> &files, const char *suffix)
|
||||
{
|
||||
for (const auto &f : files) {
|
||||
const size_t nameLen = strlen(f.file_name);
|
||||
const size_t suffixLen = strlen(suffix);
|
||||
if (nameLen >= suffixLen && strcmp(f.file_name + nameLen - suffixLen, suffix) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
rmDir(kRoot);
|
||||
FSCom.mkdir(kRoot);
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
// Leave nothing behind: the sandbox is per suite, but an undeclared write is still a finding.
|
||||
rmDir(kRoot);
|
||||
}
|
||||
|
||||
// 1. The cap is the fix from #10778 that this suite exists to pin.
|
||||
void test_getfiles_respects_max_count(void)
|
||||
{
|
||||
for (int i = 0; i < 80; i++) {
|
||||
char p[64];
|
||||
snprintf(p, sizeof(p), "%s/f%02d.txt", kRoot, i);
|
||||
makeFile(p, 4);
|
||||
}
|
||||
|
||||
bool limited = false;
|
||||
auto files = getFiles(kRoot, 1, 64, &limited);
|
||||
TEST_ASSERT_EQUAL_size_t(64, files.size());
|
||||
TEST_ASSERT_TRUE(limited);
|
||||
}
|
||||
|
||||
// 2. wasLimited must not be sticky - it is an out-param, initialised per call.
|
||||
void test_getfiles_unlimited_when_under_cap(void)
|
||||
{
|
||||
for (int i = 0; i < 5; i++) {
|
||||
char p[64];
|
||||
snprintf(p, sizeof(p), "%s/small%d.txt", kRoot, i);
|
||||
makeFile(p, 4);
|
||||
}
|
||||
|
||||
bool limited = true; // deliberately pre-set: the call must clear it
|
||||
auto files = getFiles(kRoot, 1, 64, &limited);
|
||||
TEST_ASSERT_EQUAL_size_t(5, files.size());
|
||||
TEST_ASSERT_FALSE(limited);
|
||||
}
|
||||
|
||||
// 3. Depth: a file below the requested level is absent AND reported as a truncation.
|
||||
void test_getfiles_depth_limit(void)
|
||||
{
|
||||
char dir[128];
|
||||
snprintf(dir, sizeof(dir), "%s/a", kRoot);
|
||||
FSCom.mkdir(dir);
|
||||
snprintf(dir, sizeof(dir), "%s/a/b", kRoot);
|
||||
FSCom.mkdir(dir);
|
||||
snprintf(dir, sizeof(dir), "%s/a/b/c", kRoot);
|
||||
FSCom.mkdir(dir);
|
||||
|
||||
char deep[160];
|
||||
snprintf(deep, sizeof(deep), "%s/a/b/c/deep.txt", kRoot);
|
||||
makeFile(deep, 8);
|
||||
|
||||
bool shallowLimited = false;
|
||||
auto shallow = getFiles(kRoot, 2, 64, &shallowLimited);
|
||||
TEST_ASSERT_FALSE(manifestContains(shallow, "deep.txt"));
|
||||
TEST_ASSERT_TRUE(shallowLimited);
|
||||
|
||||
bool deepLimited = false;
|
||||
auto deepFiles = getFiles(kRoot, 4, 64, &deepLimited);
|
||||
TEST_ASSERT_TRUE(manifestContains(deepFiles, "deep.txt"));
|
||||
TEST_ASSERT_FALSE(deepLimited);
|
||||
}
|
||||
|
||||
// 4. A path that will not fit meshtastic_FileInfo::file_name is dropped, not truncated into the
|
||||
// manifest, and the drop is reported.
|
||||
void test_getfiles_rejects_overlong_path(void)
|
||||
{
|
||||
// file_name is 228 bytes; build a nested path that overruns it while each component stays
|
||||
// inside the host's 255-byte limit.
|
||||
char dir[512];
|
||||
strcpy(dir, kRoot);
|
||||
for (int level = 0; level < 3; level++) {
|
||||
char component[80];
|
||||
memset(component, 'd', sizeof(component) - 1);
|
||||
component[sizeof(component) - 1] = '\0';
|
||||
snprintf(dir + strlen(dir), sizeof(dir) - strlen(dir), "/%s", component);
|
||||
FSCom.mkdir(dir);
|
||||
}
|
||||
|
||||
char longFile[600];
|
||||
snprintf(longFile, sizeof(longFile), "%s/overlong.txt", dir);
|
||||
makeFile(longFile, 4);
|
||||
|
||||
char normal[128];
|
||||
snprintf(normal, sizeof(normal), "%s/normal.txt", kRoot);
|
||||
makeFile(normal, 4);
|
||||
|
||||
bool limited = false;
|
||||
auto files = getFiles(kRoot, 5, 64, &limited);
|
||||
TEST_ASSERT_TRUE(manifestContains(files, "normal.txt"));
|
||||
TEST_ASSERT_FALSE(manifestContains(files, "overlong.txt"));
|
||||
TEST_ASSERT_TRUE(limited);
|
||||
|
||||
// rmDir() cannot reach this tree - it walks through the same 228-byte path buffer that made the
|
||||
// file unlistable in the first place - so unwind it here, deepest first.
|
||||
FSCom.remove(longFile);
|
||||
for (int level = 0; level < 3; level++) {
|
||||
FSCom.rmdir(dir);
|
||||
*strrchr(dir, '/') = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// 5. pathEndsWithDot() - no entry in the manifest may end in '.', which is how the walk filters the
|
||||
// "." and ".." pseudo-entries some backends return.
|
||||
void test_getfiles_skips_dot_entries(void)
|
||||
{
|
||||
char plain[128];
|
||||
snprintf(plain, sizeof(plain), "%s/plain.txt", kRoot);
|
||||
makeFile(plain, 4);
|
||||
|
||||
char trailingDot[128];
|
||||
snprintf(trailingDot, sizeof(trailingDot), "%s/trailing.", kRoot);
|
||||
makeFile(trailingDot, 4);
|
||||
|
||||
bool limited = false;
|
||||
auto files = getFiles(kRoot, 1, 64, &limited);
|
||||
TEST_ASSERT_TRUE(manifestContains(files, "plain.txt"));
|
||||
for (const auto &f : files) {
|
||||
const size_t len = strlen(f.file_name);
|
||||
TEST_ASSERT_TRUE_MESSAGE(len == 0 || f.file_name[len - 1] != '.', f.file_name);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. size_bytes is populated at all - the only coverage that the struct carries more than a name.
|
||||
void test_getfiles_reports_sizes(void)
|
||||
{
|
||||
char p[128];
|
||||
snprintf(p, sizeof(p), "%s/sized.txt", kRoot);
|
||||
makeFile(p, 137);
|
||||
|
||||
bool limited = false;
|
||||
auto files = getFiles(kRoot, 1, 64, &limited);
|
||||
|
||||
bool found = false;
|
||||
for (const auto &f : files) {
|
||||
if (strstr(f.file_name, "sized.txt")) {
|
||||
TEST_ASSERT_EQUAL_UINT32(137, f.size_bytes);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
TEST_ASSERT_TRUE(found);
|
||||
}
|
||||
|
||||
// 7. A directory that does not exist is empty and untruncated, not a crash.
|
||||
void test_getfiles_missing_dir_is_empty(void)
|
||||
{
|
||||
bool limited = true;
|
||||
auto files = getFiles("/test_getfiles_does_not_exist", 3, 64, &limited);
|
||||
TEST_ASSERT_EQUAL_size_t(0, files.size());
|
||||
TEST_ASSERT_FALSE(limited);
|
||||
}
|
||||
|
||||
// 8. Capacity release. PhoneAPI's releaseFilesManifest() is file-local, so this pins the idiom it
|
||||
// uses rather than calling it: a manifest reserved for 64 entries holds ~14 KB of file_name
|
||||
// buffers, and clear() returns none of it. A size-only assertion would pass on clear(), which is
|
||||
// exactly the bug #7924 shipped.
|
||||
void test_release_files_manifest_frees_capacity(void)
|
||||
{
|
||||
std::vector<meshtastic_FileInfo> manifest;
|
||||
manifest.reserve(64);
|
||||
for (int i = 0; i < 64; i++) {
|
||||
meshtastic_FileInfo info = {"", 0};
|
||||
snprintf(info.file_name, sizeof(info.file_name), "/f%02d.txt", i);
|
||||
manifest.push_back(info);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_size_t(64, manifest.size());
|
||||
TEST_ASSERT_GREATER_OR_EQUAL_size_t(64, manifest.capacity());
|
||||
|
||||
std::vector<meshtastic_FileInfo>().swap(manifest);
|
||||
|
||||
TEST_ASSERT_EQUAL_size_t(0, manifest.size());
|
||||
TEST_ASSERT_EQUAL_size_t(0, manifest.capacity());
|
||||
}
|
||||
|
||||
#endif // FSCom
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
#ifdef FSCom
|
||||
RUN_TEST(test_getfiles_respects_max_count);
|
||||
RUN_TEST(test_getfiles_unlimited_when_under_cap);
|
||||
RUN_TEST(test_getfiles_depth_limit);
|
||||
RUN_TEST(test_getfiles_rejects_overlong_path);
|
||||
RUN_TEST(test_getfiles_skips_dot_entries);
|
||||
RUN_TEST(test_getfiles_reports_sizes);
|
||||
RUN_TEST(test_getfiles_missing_dir_is_empty);
|
||||
RUN_TEST(test_release_files_manifest_frees_capacity);
|
||||
#endif
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
|
@ -60,7 +60,9 @@ void setup()
|
|||
RUN_TEST(test_timestamp_zeroed_when_rx_time_absent);
|
||||
RUN_TEST(test_encrypted_timestamp_zeroed_when_rx_time_absent);
|
||||
|
||||
UNITY_END();
|
||||
// exit(), not a bare UNITY_END(): without it setup() returns and the runtime spins loop()
|
||||
// forever, so the process never terminates even though the suite is finished.
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop()
|
||||
|
|
|
|||
|
|
@ -1195,7 +1195,7 @@ void setup()
|
|||
initializeTestEnvironment();
|
||||
LOG_WARN("This test requires the ARCH_PORTDUINO variant of WiFiClient");
|
||||
UNITY_BEGIN();
|
||||
UNITY_END();
|
||||
exit(UNITY_END());
|
||||
}
|
||||
#endif
|
||||
void loop() {}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ build_flags = ${native_base.build_flags}
|
|||
; __has_include(<led-matrix.h>) enables HAS_HUB75_NATIVE (see configuration.h). Absent -> no-op.
|
||||
!pkg-config --cflags rgbmatrix --silence-errors || :
|
||||
!pkg-config --libs rgbmatrix --silence-errors || :
|
||||
; Each test suite runs inside its own scratch $HOME. Every suite that constructs a NodeDB loads and
|
||||
; saves ~/.portduino/default/prefs/, and nothing cleared it, so state leaked suite -> suite within a
|
||||
; run and then run -> every later run: test_nodedb_blocked's deliberately saturated node database
|
||||
; was still resident 22 suites later, where test_admin_radio inherited it and failed four unrelated
|
||||
; assertions. Registered here rather than only in bin/run-tests.sh so a bare `pio test` and CI get
|
||||
; the same boundary. See bin/pio-test-isolate.sh.
|
||||
; https://docs.platformio.org/en/latest/projectconf/sections/env/options/test/test_testing_command.html
|
||||
test_testing_command =
|
||||
${platformio.src_dir}/../bin/pio-test-isolate.sh
|
||||
${platformio.build_dir}/${this.__env__}/meshtasticd
|
||||
|
||||
[env:native-tft]
|
||||
extends = native_base
|
||||
|
|
@ -120,8 +130,10 @@ build_src_filter = ${env:native-tft.build_src_filter}
|
|||
[env:coverage]
|
||||
extends = env:native
|
||||
build_flags = -lgcov --coverage -fprofile-abs-path -fsanitize=address ${env:native.build_flags}
|
||||
; Same per-suite scratch $HOME as [env:native] - see the note there and bin/pio-test-isolate.sh.
|
||||
; https://docs.platformio.org/en/latest/projectconf/sections/env/options/test/test_testing_command.html
|
||||
test_testing_command =
|
||||
${platformio.src_dir}/../bin/pio-test-isolate.sh
|
||||
${platformio.build_dir}/${this.__env__}/meshtasticd
|
||||
-s
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue