test(e2e): real-device Playwright messaging suite (#1121)

* fix(connections): connect to the just-added connection via the live store

addConnectionAndConnect() adds a connection and then connects to it in the
same tick, but connect() resolved the id against the memoized `connections`
closure, which is stale until the hook re-renders. The just-added id was
therefore reported as an unknown connection id and Save silently never
connected any HTTP/Serial/Bluetooth device. Read savedConnections from
useDeviceStore.getState() so the lookup always sees the live store.

* test(e2e): real-device Playwright messaging suite

Drives the actual web app in Chromium against real meshtasticd firmware over
the HTTP phone API and verifies text messaging in both directions across a
two-node mesh. Nodes mesh over the firmware's built-in UDP multicast
(224.0.0.69) with no MQTT/relay; distinct node numbers, real encryption.

- Default backend: two Docker meshtasticd sim nodes (daily-debian). The same
  specs run against physical hardware via E2E_DEVICE_MODE=hardware.
- An off-browser Python meshtastic peer (e2e/peer/peer.py) drives/asserts the
  non-browser node over the TCP phone API, mirroring firmware mcp-server tests.
- Coverage: connect over HTTPS, mesh->web receive, web->mesh send. Direct
  messages are fixme'd (see below). CI workflow runs it on Linux.

Bugs surfaced by the suite:
- Fixed (prior commit): connect-on-save never connected (stale-closure id
  lookup in useConnections).
- Not fixed: apps/web/src/core/subscriptions.ts throws 'ReferenceError:
  nodeDB is not defined' on every device-metrics telemetry packet (the #1050
  migration removed that store); caught per-packet, so messaging still works.
- Not fixed: direct messages are blocked by a PKI 'Keys Mismatch' (the SDK's
  stored peer public key != the key presented during NodeInfo exchange), seen
  even with fresh sim nodes.

* test(e2e): address Copilot review feedback

- waitForTcp(): destroy the probe socket on the error path so repeated
  connection failures don't accumulate sockets/FDs across the retry loop.
- Don't remove the mesh containers in Playwright globalTeardown in CI — it
  raced the workflow's failure log capture. Teardown is now gated on
  E2E_DOCKER_DOWN only; CI dumps device logs on failure and tears the mesh
  down in a final always() workflow step.

* fix(sdk): fold device-metrics telemetry into nodes

apps/web/src/core/subscriptions.ts called nodeDB.addDeviceMetrics() on every
device-metrics telemetry packet, but the #1050 migration removed that store —
so it threw 'ReferenceError: nodeDB is not defined' on each telemetry packet
(caught per-packet by the SDK's HandleFromRadio, so messaging still worked but
the error spammed the console).

Route device metrics into the SDK NodesClient via onTelemetryPacket instead —
mirroring the existing position handler; the Node domain already carries a
deviceMetrics field — and drop the dead app-side handler. Adds a NodesClient
test covering the fold.

* docs(e2e): accurate DM root cause + bug status

The direct-message fixme is a simulator limitation, not a web-app bug: the
keyless meshtasticd sim nodes NAK a DM with NO_CHANNEL (routing error 6) — no
Curve25519 keypair is provisioned/shared, and current firmware can't deliver a
direct message without a per-node key / decryptable channel. The app surfaces
this correctly (key-refresh dialog). Re-enable against hardware or once the sim
provisions keys.

Also: mark the nodeDB telemetry bug fixed and note the CI teardown change.

* docs(e2e): precise DM root cause (firmware/sim PKI)

Followed up on the suggestion to provision keys in config.security: the keys
ARE settable and persist (verified via admin), but on the native meshtasticd
sim they don't sync to the node's owner / NodeInfo key — owner.public_key stays
empty and the node keeps its MAC-derived num — so the two nodes never exchange
keys. Combined with the firmware refusing non-PKI DMs ('Unknown public key for
destination ... refusing to send legacy DM'), the DM is NAK'd with NO_CHANNEL.
A firmware/sim limitation; DMs work on real hardware. Spec stays fixme.

* docs(e2e): definitive DM root cause (SimRadio PKC payload limit)

Per the steer to research the firmware: PKI keygen is gated on a set LoRa
region (NodeDB.cpp:3051) and the sim boots region-UNSET — setting lora.region
via admin DOES make the nodes generate and exchange keys (verified both ways).
But a PKI-encrypted 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 the web app needs, so they're mutually
exclusive. DMs work on real hardware; spec stays fixme with this detail.

---------

Co-authored-by: Dan Ditomaso <dan.ditomaso@gmail.com>
This commit is contained in:
Ben Meadors
2026-06-20 11:19:50 -04:00
committed by GitHub
co-authored by Dan Ditomaso
parent a7aa25aa30
commit ad692eef74
24 changed files with 1028 additions and 15 deletions
+7
View File
@@ -0,0 +1,7 @@
# Playwright run artifacts
.results/
.report/
# Python mesh-peer virtualenv + caches
peer/.venv/
peer/__pycache__/
+132
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+131
View File
@@ -0,0 +1,131 @@
import { type ChildProcess, spawn } from "node:child_process";
import path from "node:path";
/**
* Thin TypeScript wrapper around e2e/peer/peer.py — the off-browser "mesh peer"
* that talks to the non-browser node over the TCP phone API. It sends text,
* blocks until a specific text is received, or reports the node's number.
*/
const PYTHON = process.env.E2E_PEER_PYTHON ?? path.resolve("e2e/peer/.venv/bin/python");
const SCRIPT = path.resolve("e2e/peer/peer.py");
const HOST = process.env.E2E_PEER_HOST ?? "127.0.0.1";
const PORT = process.env.E2E_PEER_PORT ?? "14404";
function spawnPeer(args: string[]): ChildProcess {
return spawn(PYTHON, [SCRIPT, "--host", HOST, "--port", PORT, ...args]);
}
/** Invoke `onLine` for each complete stdout line. */
function onStdoutLines(child: ChildProcess, onLine: (line: string) => void): void {
let buf = "";
child.stdout?.on("data", (chunk: Buffer) => {
buf += chunk.toString();
let idx: number;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (line) onLine(line);
}
});
}
/** Send a text from the peer node. Broadcast unless `to` (a node number) is given. */
export function peerSend(
text: string,
opts: { to?: number; wantAck?: boolean } = {},
): Promise<void> {
const args = ["send", text];
if (opts.to != null) args.push("--to", String(opts.to));
if (opts.wantAck) args.push("--want-ack");
const child = spawnPeer(args);
let stderr = "";
child.stderr?.on("data", (d) => {
stderr += d.toString();
});
return new Promise<void>((resolve, reject) => {
child.on("exit", (code) =>
code === 0 ? resolve() : reject(new Error(`peer send exited ${code}: ${stderr.trim()}`)),
);
child.on("error", reject);
});
}
/** Read the peer node's own node number. */
export function peerNodeNum(): Promise<number> {
const child = spawnPeer(["node-num"]);
let num: number | null = null;
let stderr = "";
child.stderr?.on("data", (d) => {
stderr += d.toString();
});
onStdoutLines(child, (line) => {
const m = line.match(/^NODE_NUM=(\d+)/);
if (m) num = Number(m[1]);
});
return new Promise<number>((resolve, reject) => {
child.on("exit", (code) =>
num != null
? resolve(num)
: reject(new Error(`peer node-num failed (${code}): ${stderr.trim()}`)),
);
child.on("error", reject);
});
}
export type RecvHandle = {
/** Resolves with the sender node number once the awaited text arrives. */
received: Promise<number>;
/** Kill the listener early. */
stop: () => void;
};
/**
* Start listening on the peer node for `text`. The returned promise resolves
* once the listener is subscribed (so the caller can then trigger the browser
* send without racing). The handle's `received` resolves when the text lands.
*/
export function startPeerRecv(
text: string,
opts: { fromNode?: number; timeout?: number } = {},
): Promise<RecvHandle> {
const { fromNode, timeout = 60 } = opts;
const args = ["recv", text, "--timeout", String(timeout)];
if (fromNode != null) args.push("--from-node", String(fromNode));
const child = spawnPeer(args);
let stderr = "";
child.stderr?.on("data", (d) => {
stderr += d.toString();
});
let resolveReceived!: (n: number) => void;
let rejectReceived!: (e: Error) => void;
const received = new Promise<number>((res, rej) => {
resolveReceived = res;
rejectReceived = rej;
});
const handle: RecvHandle = { received, stop: () => child.kill() };
return new Promise<RecvHandle>((resolveReady, rejectReady) => {
let from: number | null = null;
onStdoutLines(child, (line) => {
if (line === "READY") resolveReady(handle);
const m = line.match(/^RECEIVED=(\d+)/);
if (m) from = Number(m[1]);
});
child.on("exit", (code) => {
if (code === 0 && from != null) {
resolveReceived(from);
} else {
const err = new Error(
`peer recv exited ${code} (no match for "${text}"): ${stderr.trim()}`,
);
rejectReady(err);
rejectReceived(err);
}
});
child.on("error", (e) => {
rejectReady(e);
rejectReceived(e);
});
});
}
+32
View File
@@ -0,0 +1,32 @@
import { test as base, expect } from "@playwright/test";
import { ConnectionPage } from "../pages/ConnectionPage.ts";
import { MessagesPage } from "../pages/MessagesPage.ts";
export type DeviceInfo = {
/** host:port the browser connects to, e.g. "127.0.0.1:9443". */
host: string;
/** Whether the device webserver is HTTPS. */
tls: boolean;
};
type Fixtures = {
connectionPage: ConnectionPage;
messagesPage: MessagesPage;
device: DeviceInfo;
};
export const test = base.extend<Fixtures>({
connectionPage: async ({ page }, use) => {
await use(new ConnectionPage(page));
},
messagesPage: async ({ page }, use) => {
await use(new MessagesPage(page));
},
// oxlint-disable-next-line no-empty-pattern -- Playwright fixture with no deps
device: async ({}, use) => {
const url = new URL(process.env.E2E_NODE_A_URL ?? "https://127.0.0.1:9443");
await use({ host: url.host, tls: url.protocol === "https:" });
},
});
export { expect };
+94
View File
@@ -0,0 +1,94 @@
import { execSync } from "node:child_process";
import { request } from "node:https";
import { Socket } from "node:net";
/**
* Brings up the device topology and waits until it is reachable.
*
* - docker mode (default): starts the two `meshtasticd` sim nodes via compose.
* - hardware mode (E2E_DEVICE_MODE=hardware): skips compose; expects the env
* endpoints to point at real devices.
*/
const MODE = process.env.E2E_DEVICE_MODE ?? "docker";
const COMPOSE_FILE = "e2e/device/docker-compose.yml";
const NODE_A_URL = process.env.E2E_NODE_A_URL ?? "https://127.0.0.1:9443";
const PEER_HOST = process.env.E2E_PEER_HOST ?? "127.0.0.1";
const PEER_PORT = Number(process.env.E2E_PEER_PORT ?? 14404);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Poll the device HTTPS phone API until it answers (cert is self-signed). */
async function waitForHttps(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastErr = "connection refused";
while (Date.now() < deadline) {
const ok = await new Promise<boolean>((resolve) => {
const req = request(
url,
{ method: "GET", rejectUnauthorized: false, timeout: 4000 },
(res) => {
res.resume();
resolve((res.statusCode ?? 0) > 0);
},
);
req.on("error", (e) => {
lastErr = e.message;
resolve(false);
});
req.on("timeout", () => {
req.destroy();
resolve(false);
});
req.end();
});
if (ok) return;
await sleep(1000);
}
throw new Error(`device webserver not ready at ${url} within ${timeoutMs}ms (last: ${lastErr})`);
}
/** Poll a TCP port until it accepts a connection. */
async function waitForTcp(host: string, port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastErr = "connection refused";
while (Date.now() < deadline) {
const ok = await new Promise<boolean>((resolve) => {
const sock = new Socket();
sock.setTimeout(3000);
sock.once("connect", () => {
sock.destroy();
resolve(true);
});
sock.once("error", (e) => {
lastErr = e.message;
sock.destroy();
resolve(false);
});
sock.once("timeout", () => {
sock.destroy();
resolve(false);
});
sock.connect(port, host);
});
if (ok) return;
await sleep(1000);
}
throw new Error(
`peer node TCP not ready at ${host}:${port} within ${timeoutMs}ms (last: ${lastErr})`,
);
}
export default async function globalSetup(): Promise<void> {
if (MODE === "docker") {
console.log("[e2e] bringing up meshtasticd two-node mesh ...");
execSync(`docker compose -f ${COMPOSE_FILE} up -d`, { stdio: "inherit" });
} else {
console.log(`[e2e] hardware mode: device=${NODE_A_URL} peer=${PEER_HOST}:${PEER_PORT}`);
}
console.log(`[e2e] waiting for device webserver ${NODE_A_URL} ...`);
await waitForHttps(`${NODE_A_URL}/api/v1/fromradio?all=true`, 120_000);
console.log(`[e2e] waiting for peer node ${PEER_HOST}:${PEER_PORT} ...`);
await waitForTcp(PEER_HOST, PEER_PORT, 60_000);
console.log("[e2e] topology ready.");
}
+22
View File
@@ -0,0 +1,22 @@
import { execSync } from "node:child_process";
/**
* Tears the mesh down only on explicit request (E2E_DOCKER_DOWN=1); otherwise it
* is left running so repeated local runs are fast and node identities are stable.
*
* In CI we deliberately do NOT remove the containers here — the workflow dumps
* device logs on failure and then runs `docker compose down` as a final step, so
* removing them in this teardown would race (and discard) that log capture.
*/
const MODE = process.env.E2E_DEVICE_MODE ?? "docker";
const COMPOSE_FILE = "e2e/device/docker-compose.yml";
export default async function globalTeardown(): Promise<void> {
if (MODE !== "docker") return;
if (process.env.E2E_DOCKER_DOWN === "1") {
console.log("[e2e] tearing down meshtasticd mesh ...");
execSync(`docker compose -f ${COMPOSE_FILE} down -v`, { stdio: "inherit" });
} else {
console.log("[e2e] leaving meshtasticd mesh running (set E2E_DOCKER_DOWN=1 to stop).");
}
}
+43
View File
@@ -0,0 +1,43 @@
import { expect, type Page } from "@playwright/test";
/**
* Drives the "Add Connection" dialog to connect to a device over the HTTP(S)
* phone API. The dialog defaults to the HTTP tab; "Save connection" only enables
* after a successful "Test connection". On success the app navigates to
* /messages/broadcast/0.
*/
export class ConnectionPage {
constructor(private readonly page: Page) {}
async connectHttp(opts: { host: string; tls: boolean; name?: string }): Promise<void> {
const { host, tls, name = "E2E Device" } = opts;
const page = this.page;
await page.goto("/");
await page.getByRole("button", { name: "Add Connection" }).first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// HTTP is the default tab, but click it to be explicit/robust.
await dialog.getByRole("tab", { name: "HTTP" }).click();
await dialog.locator("#name-http").fill(name);
await dialog.locator("#url").fill(host);
const httpsSwitch = dialog.getByRole("switch");
const isChecked = (await httpsSwitch.getAttribute("aria-checked")) === "true";
if (tls !== isChecked) {
await httpsSwitch.click();
}
await dialog.getByRole("button", { name: "Test connection" }).click();
const save = dialog.getByRole("button", { name: "Save connection" });
await expect(save, "Save enables only after the device is reachable").toBeEnabled({
timeout: 20_000,
});
await save.click();
await expect(page).toHaveURL(/\/messages\/broadcast\/0/, { timeout: 60_000 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** The Messages page: compose box + rendered message list. */
export class MessagesPage {
constructor(private readonly page: Page) {}
input(): Locator {
return this.page.locator('input[name="messageInput"]');
}
sendButton(): Locator {
return this.page.locator('form[name="messageInput"] button[type="submit"]');
}
/** A rendered message bubble (an <li>) containing the given text. */
message(text: string): Locator {
return this.page.getByRole("listitem").filter({ hasText: text });
}
/** Wait until the device is configured enough that the composer is usable. */
async waitReady(): Promise<void> {
await expect(this.input()).toBeVisible({ timeout: 60_000 });
await expect(this.input()).toBeEnabled();
// The SDK chat client lags the composer (device handshake + sqlocal init), so
// an immediate send is silently dropped. Gate on the "Connected" status.
await expect(this.page.getByText("Connected", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
}
/**
* Open a direct-message thread by clicking the peer node in the sidebar
* (client-side nav, so the live connection is preserved — a full reload would
* drop it). The node's default short name is the last 4 hex digits of its
* number, e.g. 0xccddee02 -> "ee02".
*/
async openDirectMessageByNodeNum(nodeNum: number): Promise<void> {
const shortName = (nodeNum >>> 0).toString(16).slice(-4);
await this.page
.getByRole("button", { name: new RegExp(shortName, "i") })
.first()
.click();
await expect(this.input()).toBeVisible();
}
async send(text: string): Promise<void> {
await this.input().fill(text);
await this.sendButton().click();
}
async expectMessage(text: string, timeout = 45_000): Promise<void> {
await expect(this.message(text).first()).toBeVisible({ timeout });
}
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Standalone Meshtastic test peer for the web E2E suite.
Connects to a `meshtasticd` node over the TCP phone API and either sends a text
message, blocks until a specific text is received, or prints the node's own
number. It mirrors the `wait_for(predicate, timeout)` + `send_text` conventions
from `firmware/mcp-server/tests/mesh/_receive.py`, but uses `TCPInterface` so it
can talk to a daemon (that `ReceiveCollector` is serial-only).
Machine-readable stdout markers (everything else goes to stderr):
- `node-num` -> `NODE_NUM=<n>`
- `recv` -> `READY` once subscribed, then `RECEIVED=<from>` on match
Exit codes: 0 success, 1 timeout / not received, 2 usage / connection error.
Usage:
peer.py --host localhost --port 4404 send "hello" [--to <nodenum>] [--want-ack]
peer.py --host localhost --port 4403 recv "hello" [--from-node <n>] [--timeout 60]
peer.py --host localhost --port 4404 node-num
"""
from __future__ import annotations
import argparse
import sys
import threading
import time
import meshtastic
import meshtastic.tcp_interface
from pubsub import pub
def log(msg: str) -> None:
print(msg, file=sys.stderr, flush=True)
def emit(msg: str) -> None:
print(msg, flush=True)
def connect(host: str, port: int, timeout: float):
log(f"[peer] connecting to {host}:{port} ...")
iface = meshtastic.tcp_interface.TCPInterface(hostname=host, portNumber=port, connectNow=True)
deadline = time.monotonic() + timeout
while getattr(iface, "myInfo", None) is None and time.monotonic() < deadline:
time.sleep(0.1)
if getattr(iface, "myInfo", None) is None:
raise TimeoutError(f"no myInfo from {host}:{port} within {timeout}s")
log(f"[peer] connected as node {iface.myInfo.my_node_num}")
return iface
def cmd_node_num(args) -> int:
iface = connect(args.host, args.port, args.connect_timeout)
try:
emit(f"NODE_NUM={iface.myInfo.my_node_num}")
return 0
finally:
iface.close()
def cmd_send(args) -> int:
iface = connect(args.host, args.port, args.connect_timeout)
try:
dest = args.to if args.to is not None else meshtastic.BROADCAST_ADDR
pkt = iface.sendText(args.text, destinationId=dest, wantAck=args.want_ack)
pid = getattr(pkt, "id", None)
log(f"[peer] sent {args.text!r} -> {dest} (id={pid}) from {iface.myInfo.my_node_num}")
# Let the TX flush over UDP multicast before we disconnect.
time.sleep(args.linger)
emit(f"SENT={pid}")
return 0
finally:
iface.close()
def cmd_recv(args) -> int:
iface = connect(args.host, args.port, args.connect_timeout)
found = threading.Event()
hit: dict = {}
def on_text(packet, interface=None): # noqa: ANN001
decoded = (packet or {}).get("decoded", {}) or {}
if decoded.get("text") != args.text:
return
frm = packet.get("from")
if args.from_node is not None and frm != args.from_node:
return
hit["from"] = frm
found.set()
pub.subscribe(on_text, "meshtastic.receive.text")
log(f"[peer] listening for {args.text!r} on node {iface.myInfo.my_node_num} (timeout {args.timeout}s)")
emit("READY") # the test waits for this before driving the browser send
try:
if found.wait(timeout=args.timeout):
emit(f"RECEIVED={hit.get('from')}")
log(f"[peer] received {args.text!r} from {hit.get('from')}")
return 0
log(f"[peer] TIMEOUT waiting for {args.text!r}")
return 1
finally:
pub.unsubscribe(on_text, "meshtastic.receive.text")
iface.close()
def main() -> int:
ap = argparse.ArgumentParser(description="Meshtastic E2E test peer (TCP)")
ap.add_argument("--host", default="localhost")
ap.add_argument("--port", type=int, default=4403)
ap.add_argument("--connect-timeout", type=float, default=45.0)
sub = ap.add_subparsers(dest="mode", required=True)
s = sub.add_parser("send", help="send a text message")
s.add_argument("text")
s.add_argument("--to", type=int, default=None, help="destination node num (default: broadcast)")
s.add_argument("--want-ack", action="store_true")
s.add_argument("--linger", type=float, default=2.0)
s.set_defaults(func=cmd_send)
r = sub.add_parser("recv", help="wait for a specific text")
r.add_argument("text")
r.add_argument("--from-node", type=int, default=None)
r.add_argument("--timeout", type=float, default=60.0)
r.set_defaults(func=cmd_recv)
n = sub.add_parser("node-num", help="print this node's number")
n.set_defaults(func=cmd_node_num)
args = ap.parse_args()
try:
return args.func(args)
except Exception as exc: # noqa: BLE001
log(f"[peer] ERROR: {exc}")
return 2
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
# The off-browser "mesh peer" used by the E2E suite. `meshtastic` pulls in
# pypubsub (the `pubsub` module) and the protobufs it needs.
meshtastic>=2.7.8
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from "../fixtures/test.ts";
/**
* T0 — the app can connect to a REAL device over the HTTP(S) phone API and
* complete the config handshake far enough to show the messaging UI.
*/
test("connects to a real device over HTTPS and reaches the message view", async ({
page,
connectionPage,
messagesPage,
device,
}) => {
await connectionPage.connectHttp({ host: device.host, tls: device.tls });
await messagesPage.waitReady();
await expect(page).toHaveURL(/\/messages\/broadcast\/0/);
});
+31
View File
@@ -0,0 +1,31 @@
import { peerSend, startPeerRecv } from "../fixtures/peer.ts";
import { expect, test } from "../fixtures/test.ts";
/**
* Broadcast text messaging across a real two-node mesh:
* - mesh -> web: the peer node broadcasts; the browser must render it.
* - web -> mesh: the browser sends; the peer node must receive it.
* Both directions traverse real firmware over the UDP-multicast mesh.
*/
test.describe("broadcast messaging over a real two-node mesh", () => {
test.beforeEach(async ({ connectionPage, messagesPage, device }) => {
await connectionPage.connectHttp({ host: device.host, tls: device.tls });
await messagesPage.waitReady();
});
test("renders a broadcast received from a mesh peer (mesh -> web)", async ({ messagesPage }) => {
const nonce = `pong-${Date.now()}`;
await peerSend(nonce);
await messagesPage.expectMessage(nonce);
});
test("delivers a typed broadcast to the mesh (web -> mesh)", async ({ messagesPage }) => {
const nonce = `ping-${Date.now()}`;
// Listen on the peer node before sending, then type+send from the browser.
const recv = await startPeerRecv(nonce, { timeout: 60 });
await messagesPage.send(nonce);
// The peer node confirms the text actually traversed the real mesh.
const from = await recv.received;
expect(from).toBeGreaterThan(0);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { peerNodeNum, startPeerRecv } from "../fixtures/peer.ts";
import { expect, test } from "../fixtures/test.ts";
/**
* Direct (node-addressed) messaging across the real two-node mesh.
*
* NOTE: marked fixme — this is a SIMULATOR limitation, not a web-app issue, and
* it bottoms out in the firmware's SimRadio:
* - DMs go out PKI-encrypted. PKI key generation is gated on a set LoRa region
* (NodeDB.cpp:3051 — keygen is skipped while region == UNSET) and the sim
* nodes boot region-UNSET. Setting `lora.region` via admin DOES fix key gen
* + exchange (verified: both nodes learn each other's public key).
* - But a PKI-encrypted DM still can't traverse the SimRadio: the PKC overhead
* pushes the packet past the SimRadio payload limit ("Payload size larger
* than compressed message allows! Send empty payload"), so it is truncated
* and the receiver can't decode it ("No suitable channel found for decoding,
* hash 0x0") -> NO_CHANNEL. The firmware deliberately skips PKC under the
* --sim flag (Router.cpp:730) for exactly this reason, but --sim also
* disables config-file loading (Webserver/EnableUDP/MAC) that the web app's
* HTTP API + UDP mesh require, so the two are mutually exclusive.
* The web app behaves correctly (it raises the key-refresh dialog). Broadcast
* already covers bidirectional messaging; re-enable against real hardware, where
* real LoRa carries PKC fine.
*/
test.describe("direct messaging over a real two-node mesh", () => {
test.beforeEach(async ({ connectionPage, messagesPage, device }) => {
await connectionPage.connectHttp({ host: device.host, tls: device.tls });
await messagesPage.waitReady();
});
test.fixme(
"delivers a direct message from the browser to the peer node (web -> mesh)",
async ({ messagesPage }) => {
const peerNum = await peerNodeNum();
const nonce = `dm-${Date.now()}`;
const recv = await startPeerRecv(nonce, { timeout: 60 });
await messagesPage.openDirectMessageByNodeNum(peerNum);
await messagesPage.send(nonce);
const from = await recv.received;
expect(from).toBeGreaterThan(0);
},
);
});