#!/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")" # Error-line budget: same manifest, same declare-and-justify shape as the writes above. Counted from # the captured log, so it costs nothing extra. ERROR_COUNT="$(state_count_errors "$LOG")" IFS=$'\t' read -r ERROR_VERDICT ERROR_DETAIL <<<"$(state_classify_errors "$ERROR_COUNT" "$(state_flag_value errors "$FLAGS")")" # 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\t%s\t%s\n' "$SUITE" "$STATUS" "$VERDICT" "${DETAIL-}" "${PER_TEST_DETAIL-}" \ "${SURVIVORS-}" "${ERROR_VERDICT-}" "${ERROR_DETAIL-}" >>"$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 || $ERROR_VERDICT != WITHIN || -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"