6125130e6a
Run 10538 booted the full simulator and ran pytest — both failures were test-side: the installed meshtastic client moved sendText from Node to the Interface, and the outbound chunked message never arrived at the peer (cause still unknown). Fix the sendText call and add in-run diagnostics so the next failure is self-explaining: - e2e: outbound-timeout failure now reports what arrived at the peer and the simulator log tail; sim now boots with -v (meshtastic debug logging). - run_e2e.sh: on pytest failure in docker mode, dump per-node meshtasticd logs (meshtasticator-nodes.log) before cleanup removes the container.
236 lines
8.9 KiB
Bash
Executable File
236 lines
8.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Boot N Meshtasticator nodes and run the plugin's radio-level e2e tests
|
|
# (plugin/tests/e2e) against node 0.
|
|
#
|
|
# Meshtasticator's interactive simulator (https://meshtastic.org/docs/software/meshtasticator/)
|
|
# runs the real MeshtasticD device software per node and simulates the LoRa
|
|
# PHY, so these tests exercise the adapter's real TCP/protobuf session,
|
|
# inbound/outbound text flow across the simulated mesh, and chunked sends —
|
|
# without physical radios. It cannot validate silicon-level RF.
|
|
#
|
|
# Prerequisites
|
|
# * Linux host, OR Docker (macOS/Windows): the simulator auto-falls back to
|
|
# Docker on non-Linux and can build MeshtasticD itself (`-d`).
|
|
# * Native Linux mode: build MeshtasticD's 'native' target (PlatformIO,
|
|
# select 'native') and pass --program <dir containing binary 'program'>.
|
|
# * uv + python 3.13 for the contract venv (plugin/.venv-contract).
|
|
#
|
|
# Docker on a remote daemon: if DOCKER_HOST points at a tcp:// daemon, the
|
|
# simulator's own control connections assume nodes listen on localhost, so
|
|
# this script starts lightweight localhost->daemon TCP forwarders for the node
|
|
# ports first (scripts/meshtasticator_e2e/tcp_forward.py). Nothing is
|
|
# forwarded when the daemon is local (unset/unix DOCKER_HOST).
|
|
#
|
|
# Usage
|
|
# MESHTASTICATOR_DIR=/path/to/meshtasticator scripts/meshtasticator_e2e/run_e2e.sh
|
|
# # docker mode (default): ... run_e2e.sh --mode docker
|
|
# # native mode: ... run_e2e.sh --mode native --program /path/to/firmware/.pio/build/native
|
|
# # node count / channel: ... run_e2e.sh --nodes 3 --channel-index 0
|
|
#
|
|
# Environment
|
|
# MESHTASTICATOR_DIR path to a clone of github.com/meshtastic/meshtasticator
|
|
# E2E_VENV python env with hermes-agent + plugin deps installed
|
|
# (default: plugin/.venv-contract — see scripts/contract-test.sh,
|
|
# then `uv pip install --python plugin/.venv-contract/bin/python -e plugin`)
|
|
# SIM_VENV optional python env holding the simulator's deps
|
|
# (meshtasticator/requirements.txt + `pip install docker`);
|
|
# defaults to plain `python3`.
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
SIM_DIR="${MESHTASTICATOR_DIR:?set MESHTASTICATOR_DIR to a meshtasticator clone}"
|
|
NODES=3
|
|
MODE=docker
|
|
PROGRAM=""
|
|
CHANNEL_INDEX=0
|
|
HOST=127.0.0.1
|
|
VENV="${E2E_VENV:-$REPO_ROOT/plugin/.venv-contract}"
|
|
if [ -n "${SIM_VENV:-}" ]; then
|
|
SIM_PYTHON="$SIM_VENV/bin/python"
|
|
else
|
|
SIM_PYTHON="${SIM_PYTHON:-python3}"
|
|
fi
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--nodes) NODES="$2"; shift 2 ;;
|
|
--mode) MODE="$2"; shift 2 ;;
|
|
--program) PROGRAM="$2"; shift 2 ;;
|
|
--channel-index) CHANNEL_INDEX="$2"; shift 2 ;;
|
|
--host) HOST="$2"; shift 2 ;;
|
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
command -v uv >/dev/null 2>&1 || { echo "uv required" >&2; exit 1; }
|
|
[ -d "$SIM_DIR" ] || { echo "meshtasticator not found at $SIM_DIR" >&2; exit 1; }
|
|
[ -x "$VENV/bin/python" ] || { echo "e2e venv missing at $VENV (see scripts/contract-test.sh)" >&2; exit 1; }
|
|
|
|
export MPLBACKEND=Agg # headless matplotlib (no display needed)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Remote-daemon port forwarding: Meshtasticator talks to nodes on localhost.
|
|
# ---------------------------------------------------------------------------
|
|
DOCKER_TARGET=""
|
|
if [[ "${DOCKER_HOST:-}" == tcp://* ]]; then
|
|
DOCKER_TARGET="${DOCKER_HOST#tcp://}"
|
|
DOCKER_TARGET="${DOCKER_TARGET%%:*}"
|
|
if [ "$DOCKER_TARGET" = "127.0.0.1" ] || [ "$DOCKER_TARGET" = "localhost" ]; then
|
|
DOCKER_TARGET=""
|
|
fi
|
|
fi
|
|
FWD_PIDS=()
|
|
PORT_BASE=4404 # matches meshtasticator lib/interactive.py TCP_PORT_OFFSET
|
|
|
|
start_forwards() {
|
|
for ((i = 0; i < NODES; i++)); do
|
|
port=$((PORT_BASE + i))
|
|
"$VENV/bin/python" "$REPO_ROOT/scripts/meshtasticator_e2e/tcp_forward.py" \
|
|
--listen "127.0.0.1:$port" --target "$DOCKER_TARGET:$port" >/dev/null 2>&1 &
|
|
FWD_PIDS+=("$!")
|
|
done
|
|
sleep 1
|
|
}
|
|
|
|
SIM_LOG="$REPO_ROOT/meshtasticator-sim.log"
|
|
SIM_ARGS=("$NODES" -v) # -v: meshtastic/debug logging for RF diagnostics
|
|
if [ "$MODE" = native ]; then
|
|
[ -n "$PROGRAM" ] && [ -d "$PROGRAM" ] || { echo "--mode native needs --program <dir>" >&2; exit 2; }
|
|
SIM_ARGS+=(-p "$PROGRAM")
|
|
else
|
|
SIM_ARGS+=(-d)
|
|
fi
|
|
|
|
if [ -n "$DOCKER_TARGET" ]; then
|
|
echo "== remote docker daemon detected ($DOCKER_TARGET): starting localhost port forwards =="
|
|
start_forwards
|
|
PROBE_HOST="$DOCKER_TARGET"
|
|
else
|
|
PROBE_HOST="$HOST"
|
|
fi
|
|
|
|
# The simulator assumes an interactive Tk desktop: lib/gui.py forces
|
|
# matplotlib.use("TkAgg") at import and move_figure() touches the Tk window
|
|
# manager at Graph construction — both crash headless runs even under
|
|
# MPLBACKEND=Agg. Neutralize them in the pinned checkout (only the interactive
|
|
# GUI modes, never used by this harness, are lost).
|
|
echo "== patching meshtasticator lib/gui.py for headless runs =="
|
|
"$VENV/bin/python" - "$SIM_DIR" <<'PY'
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
gui = Path(sys.argv[1]) / "lib/gui.py"
|
|
src = gui.read_text()
|
|
|
|
src = src.replace('matplotlib.use("TkAgg")', 'matplotlib.use("Agg")')
|
|
|
|
old = 'def move_figure(fig, x, y):\n fig.canvas.manager.window.wm_geometry("+%d+%d" % (x, y))'
|
|
new = ('def move_figure(fig, x, y):\n'
|
|
' # Headless (Agg) backends have no Tk window manager.\n'
|
|
' try:\n'
|
|
' fig.canvas.manager.window.wm_geometry("+%d+%d" % (x, y))\n'
|
|
' except AttributeError:\n'
|
|
' pass')
|
|
assert old in src, "move_figure source no longer matches the pinned meshtasticator"
|
|
src = src.replace(old, new)
|
|
gui.write_text(src)
|
|
print("patched lib/gui.py")
|
|
PY
|
|
|
|
if [ "$MODE" = docker ]; then
|
|
# Warm the node image on the (possibly remote) daemon first: the simulator
|
|
# starts node processes ~4s after the container is created and control
|
|
# connections must succeed quickly, so the pull cannot sit in that path.
|
|
echo "== warming meshtastic/meshtasticd image =="
|
|
docker pull meshtastic/meshtasticd
|
|
fi
|
|
|
|
echo "== booting Meshtasticator ($MODE) with $NODES node(s) from $SIM_DIR =="
|
|
# The simulator ends in an interactive cmdloop reading stdin; an EOF there
|
|
# (e.g. CI) would make it exit right after booting the nodes. Feed it from a
|
|
# FIFO whose write end this shell keeps open until cleanup.
|
|
SIM_FIFO="$(mktemp -u /tmp/meshtasticator-XXXXXX.fifo)"
|
|
mkfifo "$SIM_FIFO"
|
|
(
|
|
cd "$SIM_DIR"
|
|
"$SIM_PYTHON" interactiveSim.py "${SIM_ARGS[@]}" <"$SIM_FIFO" >"$SIM_LOG" 2>&1
|
|
) &
|
|
SIM_PID=$!
|
|
exec 9>"$SIM_FIFO" # hold the write end open -> sim stdin never hits EOF
|
|
rm -f "$SIM_FIFO"
|
|
|
|
cleanup() {
|
|
echo "== tearing down simulator (pid $SIM_PID) =="
|
|
exec 9>&- 2>/dev/null || true # release the FIFO write end (sim stdin EOF)
|
|
kill "$SIM_PID" 2>/dev/null || true
|
|
wait "$SIM_PID" 2>/dev/null || true
|
|
for pid in "${FWD_PIDS[@]:-}"; do
|
|
kill "$pid" 2>/dev/null || true
|
|
done
|
|
if [ "$MODE" = docker ]; then
|
|
# The sim names its container/volume (auto_remove only fires on stop).
|
|
docker rm -f Meshtastic >/dev/null 2>&1 || true
|
|
docker volume rm Meshtasticator >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
echo "== waiting for $NODES node TCP API port(s) to accept connections =="
|
|
READY=""
|
|
for _ in $(seq 1 180); do # up to ~5 min: docker pulls meshtastic/meshtasticd on first run
|
|
# Probe the upstream node host (the docker daemon when forwards are active,
|
|
# localhost otherwise) — probing the forwarders themselves would always pass.
|
|
READY="$("$VENV/bin/python" - "$PROBE_HOST" "$NODES" <<'PY' || true
|
|
import socket, sys
|
|
host, count = sys.argv[1], int(sys.argv[2])
|
|
found = []
|
|
# Meshtasticator assigns node n -> TCP port starting at 4404 (see
|
|
# lib/interactive.py TCP_PORT_OFFSET); probe a small window to be safe.
|
|
for port in range(4403, 4403 + 16):
|
|
try:
|
|
with socket.create_connection((host, port), timeout=0.5):
|
|
found.append(port)
|
|
except OSError:
|
|
pass
|
|
if len(found) >= count:
|
|
break
|
|
print(",".join(map(str, found[:count])))
|
|
PY
|
|
)"
|
|
if [ -n "$READY" ]; then
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ -z "$READY" ]; then
|
|
echo "error: no Meshtasticator node ports became reachable on $PROBE_HOST (see $SIM_LOG)" >&2
|
|
exit 1
|
|
fi
|
|
echo " node ports: $READY (probed on $PROBE_HOST)"
|
|
|
|
# Ports accept before the node firmware finished booting inside the container;
|
|
# give meshtasticd a moment to open its TCP API for real.
|
|
echo "== settling (node firmware boot) =="
|
|
sleep 15
|
|
|
|
echo "== running radio-level e2e tests =="
|
|
(
|
|
cd "$REPO_ROOT"
|
|
MESHTASTICATOR_E2E=1 \
|
|
E2E_HOST="$HOST" \
|
|
E2E_PORTS="$READY" \
|
|
E2E_CHANNEL_INDEX="$CHANNEL_INDEX" \
|
|
"$VENV/bin/python" -m pytest plugin/tests/e2e -q "$@"
|
|
)
|
|
RC=$?
|
|
|
|
if [ $RC -ne 0 ] && [ "$MODE" = docker ]; then
|
|
# Capture per-node meshtasticd logs before cleanup removes the container.
|
|
echo "== dumping node logs (pytest rc=$RC) =="
|
|
docker exec Meshtastic sh -c 'for f in /home/out_*.log; do echo "----- $f -----"; tail -n 60 "$f" 2>/dev/null; done' \
|
|
>"$REPO_ROOT/meshtasticator-nodes.log" 2>&1 || true
|
|
echo " node logs: $REPO_ROOT/meshtasticator-nodes.log"
|
|
fi
|
|
|
|
exit $RC
|