Playwright tests that drive the real web app in Chromium against a real Meshtastic device over the HTTP(S) phone API and verify text messaging in both directions across a two-node mesh.
By default the "devices" are two simulated meshtasticd firmware nodes running
in Docker, meshed over the firmware's built-in UDP multicast LAN transport
(224.0.0.69:4403) — real firmware, real encryption, distinct node numbers, and
no MQTT/relay. The same tests can run against physical hardware.
Playwright (headless Chromium) Python peer (meshtastic lib)
── HTTPS phone API :9443 ────┐ ┌──── TCP phone API :4403 ────
▼ ▼
┌─────────────────┐ UDP multicast ┌─────────────────┐
│ Node A (DUT) │ 224.0.0.69 │ Node B (peer) │
│ meshtasticd sim │◀─── mesh ──────▶│ meshtasticd sim │
└─────────────────┘ └─────────────────┘
- Node A is the device-under-test the browser connects to (HTTPS).
- Node B is driven/observed by the Python peer (
e2e/peer/peer.py) over TCP.
| Path | What |
|---|---|
playwright.config.ts |
Config (root): chromium, serial, dev server on :3100, global setup/teardown |
e2e/global-setup.ts / global-teardown.ts |
Bring the mesh up / wait for readiness / tear down |
e2e/device/docker-compose.yml + nodeA.yaml / nodeB.yaml |
The two meshtasticd sim nodes |
e2e/peer/peer.py + requirements.txt |
The off-browser mesh peer (TCP meshtastic lib) |
e2e/fixtures/ |
peer.ts (peer wrapper) + test.ts (page-object + device fixtures) |
e2e/pages/ |
ConnectionPage.ts, MessagesPage.ts |
e2e/tests/ |
connect, messaging.broadcast, messaging.direct |
Prerequisites: Docker, Node + pnpm (11.5.2), Python 3.11+.
pnpm install
pnpm exec playwright install chromium
python -m venv e2e/peer/.venv && e2e/peer/.venv/bin/pip install -r e2e/peer/requirements.txt
pnpm test:e2e # brings up the mesh, runs the suite
pnpm test:e2e:report # open the HTML reportGlobal setup runs docker compose up -d (idempotent) and waits for the device.
The mesh is left running between runs for speed; set E2E_DOCKER_DOWN=1 to
tear it down on exit. CI leaves the containers up through the run (so the
workflow can dump device logs on failure) and tears them down in a final step.
| Var | Default | Purpose |
|---|---|---|
E2E_DEVICE_MODE |
docker |
docker (sim) or hardware (skip compose) |
E2E_NODE_A_URL |
https://127.0.0.1:9443 |
Device-under-test the browser connects to |
E2E_PEER_HOST / E2E_PEER_PORT |
127.0.0.1 / 14404 |
TCP phone API the Python peer drives |
E2E_WEB_PORT |
3100 |
Dev-server port for the app under test |
E2E_PEER_PYTHON |
e2e/peer/.venv/bin/python |
Python used to run the peer |
E2E_DOCKER_DOWN |
unset | 1 to compose down on teardown (CI tears down in a final workflow step) |
Point the suite at two physical devices on the same channel/region. Node A must expose the HTTP(S) phone API (Wi-Fi); the peer reaches Node B over TCP (Wi-Fi) — both on the same LoRa mesh, so the radio is the bridge (no MQTT):
E2E_DEVICE_MODE=hardware \
E2E_NODE_A_URL=https://<deviceA-ip> \
E2E_PEER_HOST=<deviceB-ip> E2E_PEER_PORT=4403 \
pnpm test:e2e- connect — add an HTTP(S) connection in the UI, complete the config handshake, land on the messages view.
- messaging.broadcast —
mesh → web(peer broadcasts, the browser renders it) andweb → mesh(the browser sends, the peer node confirms receipt over the real mesh). - messaging.direct —
fixme(see Known limitations).
- Image tag: use
meshtastic/meshtasticd:daily-debian.:latestis2.7.15and predates theEnableUDPmulticast feature (no mesh between sim nodes). - Do not pass
--sim:force_simradiotakes an early branch inportduinoSetup()that skips config-file loading — Webserver /EnableUDP/MACAddresswould all be ignored. Select the sim radio viaLora: Module: simin the config instead. - Distinct
MACAddressper node → distinct node numbers (else the UDP handler drops the peer's packets as "spoofed local origin"). - Webserver is HTTPS-only (self-signed cert on 9443) — Playwright uses
ignoreHTTPSErrors+--ignore-certificate-errors; the dialog's HTTPS toggle is on. The app is served over plain HTTP to avoid mixed-content. - Send readiness: the composer renders before the SDK chat client is ready
(the SQLite/OPFS
sqlocalstore times out in headless Chromium and falls back to in-memory).MessagesPage.waitReady()gates on the "Connected" status so an immediate send isn't silently dropped.
- Direct messages (
messaging.direct) arefixme— a SimRadio limitation, not a web-app issue. DMs go out PKI-encrypted. PKI keygen is gated on a set LoRa region (NodeDB.cpp:3051; the sim boots region-UNSET) — settinglora.regionvia admin does make the nodes generate and exchange keys (verified: both learn each other's public key). But a PKI DM still can't traverse the SimRadio: the PKC overhead exceeds its payload limit (Payload size larger than compressed message allows! Send empty payload), so the packet is truncated and the receiver NAKsNO_CHANNEL(No suitable channel found for decoding, hash 0x0). The firmware skips PKC under--sim(Router.cpp:730) for exactly this reason, but--simalso disables the config-file loading (Webserver/EnableUDP/MAC) the web app needs, so the two are mutually exclusive. The app behaves correctly (key-refresh dialog). Broadcast covers bidirectional messaging; re-enable against real hardware, where real LoRa carries PKC fine.
- Connect-on-save race (
apps/web/src/pages/Connections/useConnections.ts):connect()read the just-added connection from a stale memoized closure, so "Save" never actually connected ("unknown connection id"). Fixed to read from the live store. ReferenceError: nodeDB is not defined(apps/web/src/core/subscriptions.ts): the device-metrics telemetry handler called a node store the #1050 migration removed, throwing on every telemetry packet. Fixed by folding device metrics into the node inside the SDKNodesClient(onTelemetryPacket) and dropping the dead app-side handler.