Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Real-device end-to-end suite: drives the web app in Chromium against two
# simulated `meshtasticd` firmware nodes (UDP-multicast mesh, no MQTT) and
# exercises text messaging in both directions. See e2e/README.md.
name: E2E (real device)

on:
workflow_dispatch:
pull_request:
paths:
- "e2e/**"
- "apps/web/**"
- "packages/**"
- "playwright.config.ts"
- ".github/workflows/e2e.yml"

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CI: "true"
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 11.5.2

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install workspace deps
run: pnpm install --frozen-lockfile

- name: Install Chromium
run: pnpm exec playwright install --with-deps chromium

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install Python mesh peer
run: |
python -m venv e2e/peer/.venv
e2e/peer/.venv/bin/pip install -r e2e/peer/requirements.txt

- name: Run E2E suite
run: pnpm test:e2e

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: e2e/.report
retention-days: 7

- name: Dump device logs on failure
if: failure()
run: docker compose -f e2e/device/docker-compose.yml logs --no-color || true

- name: Tear down mesh
if: always()
run: docker compose -f e2e/device/docker-compose.yml down -v || true
16 changes: 3 additions & 13 deletions apps/web/src/core/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,6 @@ export const subscribeAll = (device: Device, connection: MeshDevice) => {
}
});

connection.events.onTelemetryPacket.subscribe((telemetryPacket) => {
// Fold live device-metrics telemetry into the node so battery / channel
// utilisation / voltage stay current between NodeInfo broadcasts.
if (telemetryPacket.data.variant.case === "deviceMetrics") {
nodeDB.addDeviceMetrics({
...telemetryPacket,
data: telemetryPacket.data.variant.value,
});
}
});

connection.events.onDeviceStatus.subscribe((status) => {
device.setStatus(status);
});
Expand All @@ -61,8 +50,9 @@ export const subscribeAll = (device: Device, connection: MeshDevice) => {
useNewNodeNum(device.id, nodeInfo);
});

// onUserPacket / onPositionPacket / onNodeInfoPacket are handled by the
// SDK NodesClient (see packages/sdk/src/features/nodes/NodesClient.ts).
// onUserPacket / onPositionPacket / onNodeInfoPacket and device-metrics
// telemetry (battery / channel utilisation / voltage) are folded into nodes by
// the SDK NodesClient (see packages/sdk/src/features/nodes/NodesClient.ts).

connection.events.onChannelPacket.subscribe((channel) => {
device.addChannel(channel);
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/pages/Connections/useConnections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ export function useConnections() {

const connect = useCallback(
async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
const conn = connections.find((c) => c.id === id);
// Read from the live store, not the memoized `connections` closure: callers
// such as addConnectionAndConnect() add a connection and connect to it in the
// same tick, before this hook re-renders, so the closure would be stale.
const conn = useDeviceStore.getState().savedConnections.find((c) => c.id === id);
if (!conn) {
log.warn("connect: unknown connection id", { id });
return false;
Expand Down Expand Up @@ -271,7 +274,7 @@ export function useConnections() {
return false;
}
},
[connections, updateStatus, setupMeshDevice],
[updateStatus, setupMeshDevice],
);

const disconnect = useCallback(
Expand Down
7 changes: 7 additions & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Playwright run artifacts
.results/
.report/

# Python mesh-peer virtualenv + caches
peer/.venv/
peer/__pycache__/
132 changes: 132 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Real-device E2E messaging suite

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.

## Layout

| 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` |

## Running locally (Docker sim — default)

Prerequisites: Docker, Node + pnpm (`11.5.2`), Python 3.11+.

```bash
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 report
```

Global 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.

## Environment variables

| 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) |

## Running against real hardware

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):

```bash
E2E_DEVICE_MODE=hardware \
E2E_NODE_A_URL=https://<deviceA-ip> \
E2E_PEER_HOST=<deviceB-ip> E2E_PEER_PORT=4403 \
pnpm test:e2e
```

## What the tests cover

- **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) and `web → mesh` (the browser sends, the peer node confirms receipt over
the real mesh).
- **messaging.direct** — `fixme` (see Known limitations).

## Gotchas baked in (firmware/sim specifics)

- **Image tag**: use `meshtastic/meshtasticd:daily-debian`. `:latest` is `2.7.15`
and predates the `EnableUDP` multicast feature (no mesh between sim nodes).
- **Do not pass `--sim`**: `force_simradio` takes an early branch in
`portduinoSetup()` that skips config-file loading — Webserver / `EnableUDP` /
`MACAddress` would all be ignored. Select the sim radio via `Lora: Module: sim`
in the config instead.
- **Distinct `MACAddress`** per 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 `sqlocal` store 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.

## Known limitations

- **Direct messages (`messaging.direct`) are `fixme` — 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) — setting `lora.region`
via 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 NAKs `NO_CHANNEL` (`No suitable channel found for decoding, hash 0x0`).
The firmware skips PKC under `--sim` (Router.cpp:730) for exactly this reason,
but `--sim` also 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.

## App bugs surfaced by this suite (fixed on this branch)

1. **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.
2. **`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 SDK `NodesClient` (`onTelemetryPacket`) and dropping
the dead app-side handler.
41 changes: 41 additions & 0 deletions e2e/device/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Two real meshtasticd firmware nodes in simulated-radio mode, meshed over the
# firmware's built-in UDP multicast (no MQTT, no relay). Node A exposes the HTTPS
# phone API for the browser; Node B exposes the TCP phone API for the Python peer.
#
# Multicast (224.0.0.69:4403) is flooded between the two containers by the Linux
# bridge (no IGMP querier on a default docker network => multicast floods).
name: meshtastic-e2e

services:
node-a:
image: meshtastic/meshtasticd:daily-debian
container_name: meshtastic-e2e-node-a
# NB: simulated radio is selected via `Lora: Module: sim` in the mounted
# config.yaml — NOT the --sim CLI flag. --sim/force_simradio takes an early
# branch in portduinoSetup() that skips config-file loading entirely
# (firmware PortduinoGlue.cpp:235), which would drop our Webserver/EnableUDP/MAC.
command: ["sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd"]
volumes:
- ./nodeA.yaml:/etc/meshtasticd/config.yaml:ro
ports:
- "9443:9443" # HTTPS phone API — browser / device-under-test
- "14403:4403" # TCP phone API — debug only
networks: [mesh]

node-b:
image: meshtastic/meshtasticd:daily-debian
container_name: meshtastic-e2e-node-b
# NB: simulated radio is selected via `Lora: Module: sim` in the mounted
# config.yaml — NOT the --sim CLI flag. --sim/force_simradio takes an early
# branch in portduinoSetup() that skips config-file loading entirely
# (firmware PortduinoGlue.cpp:235), which would drop our Webserver/EnableUDP/MAC.
command: ["sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd"]
volumes:
- ./nodeB.yaml:/etc/meshtasticd/config.yaml:ro
ports:
- "14404:4403" # TCP phone API — Python peer
networks: [mesh]

networks:
mesh:
driver: bridge
19 changes: 19 additions & 0 deletions e2e/device/nodeA.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Node A — the device-under-test the BROWSER connects to over the HTTPS phone API.
# Simulated radio + UDP-multicast LAN mesh so it can hear Node B with no MQTT.
# The webserver (ulfius) is TLS-only with a self-signed cert generated on first
# boot, so the browser connects via https:// with cert errors ignored.
---
Lora:
Module: sim
Config:
# Enables the firmware's UDP-multicast LAN mesh (224.0.0.69:4403).
# See firmware/src/platform/portduino/PortduinoGlue.cpp (EnableUDP) and
# firmware/src/mesh/udp/UdpMulticastHandler.h.
EnableUDP: true
Webserver:
Port: 9443
General:
# Distinct MAC => distinct node number from Node B.
MACAddress: AA:BB:CC:DD:EE:01
Logging:
LogLevel: info
11 changes: 11 additions & 0 deletions e2e/device/nodeB.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Node B — the peer node driven/observed by the Python peer over the TCP phone API.
# Same simulated radio + UDP mesh as Node A, distinct node number, no webserver.
---
Lora:
Module: sim
Config:
EnableUDP: true
General:
MACAddress: AA:BB:CC:DD:EE:02
Logging:
LogLevel: info
Loading
Loading