mirror of
https://github.com/meshtastic/web.git
synced 2026-08-01 07:26:34 -04:00
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:
66
.github/workflows/e2e.yml
vendored
Normal file
66
.github/workflows/e2e.yml
vendored
Normal file
@@ -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
|
||||
@@ -39,17 +39,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);
|
||||
});
|
||||
@@ -63,8 +52,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);
|
||||
|
||||
@@ -246,7 +246,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;
|
||||
@@ -308,7 +311,7 @@ export function useConnections() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[connections, updateStatus, setupMeshDevice],
|
||||
[updateStatus, setupMeshDevice],
|
||||
);
|
||||
|
||||
const disconnect = useCallback(
|
||||
|
||||
7
e2e/.gitignore
vendored
Normal file
7
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Playwright run artifacts
|
||||
.results/
|
||||
.report/
|
||||
|
||||
# Python mesh-peer virtualenv + caches
|
||||
peer/.venv/
|
||||
peer/__pycache__/
|
||||
132
e2e/README.md
Normal file
132
e2e/README.md
Normal 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
e2e/device/docker-compose.yml
Normal file
41
e2e/device/docker-compose.yml
Normal 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
e2e/device/nodeA.yaml
Normal file
19
e2e/device/nodeA.yaml
Normal 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
e2e/device/nodeB.yaml
Normal file
11
e2e/device/nodeB.yaml
Normal 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
e2e/fixtures/peer.ts
Normal file
131
e2e/fixtures/peer.ts
Normal 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
e2e/fixtures/test.ts
Normal file
32
e2e/fixtures/test.ts
Normal 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
e2e/global-setup.ts
Normal file
94
e2e/global-setup.ts
Normal 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
e2e/global-teardown.ts
Normal file
22
e2e/global-teardown.ts
Normal 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
e2e/pages/ConnectionPage.ts
Normal file
43
e2e/pages/ConnectionPage.ts
Normal 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
e2e/pages/MessagesPage.ts
Normal file
54
e2e/pages/MessagesPage.ts
Normal 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
e2e/peer/peer.py
Normal file
140
e2e/peer/peer.py
Normal 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
e2e/peer/requirements.txt
Normal file
3
e2e/peer/requirements.txt
Normal 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
e2e/tests/connect.spec.ts
Normal file
16
e2e/tests/connect.spec.ts
Normal 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
e2e/tests/messaging.broadcast.spec.ts
Normal file
31
e2e/tests/messaging.broadcast.spec.ts
Normal 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
e2e/tests/messaging.direct.spec.ts
Normal file
43
e2e/tests/messaging.direct.spec.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,9 @@
|
||||
"clean:all": "pnpm run --filter '*' clean",
|
||||
"publish:packages": "pnpm run build --filter 'packages/transport-*'",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/.report",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -35,6 +38,7 @@
|
||||
"devDependencies": {
|
||||
"@meshtastic/core": "jsr:^2.6.6",
|
||||
"@meshtastic/sdk": "workspace:*",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^25.9.3",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^17.0.7",
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as Protobuf from "@meshtastic/protobufs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MeshClient } from "../../core/client/MeshClient.ts";
|
||||
import { createFakeTransport } from "../../core/testing/createFakeTransport.ts";
|
||||
import { ChannelNumber } from "../../core/types.ts";
|
||||
|
||||
describe("NodesClient", () => {
|
||||
it("populates the list signal from incoming NodeInfo packets", async () => {
|
||||
@@ -27,4 +28,31 @@ describe("NodesClient", () => {
|
||||
const client = new MeshClient({ transport });
|
||||
expect(client.nodes.byNum(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("folds live device-metrics telemetry into the node", () => {
|
||||
const { transport } = createFakeTransport();
|
||||
const client = new MeshClient({ transport });
|
||||
|
||||
client.events.onNodeInfoPacket.dispatch(create(Protobuf.Mesh.NodeInfoSchema, { num: 100 }));
|
||||
client.events.onTelemetryPacket.dispatch({
|
||||
id: 1,
|
||||
from: 100,
|
||||
to: 0,
|
||||
channel: ChannelNumber.Primary,
|
||||
type: "broadcast",
|
||||
rxTime: new Date(1000),
|
||||
data: create(Protobuf.Telemetry.TelemetrySchema, {
|
||||
variant: {
|
||||
case: "deviceMetrics",
|
||||
value: create(Protobuf.Telemetry.DeviceMetricsSchema, {
|
||||
batteryLevel: 80,
|
||||
voltage: 4.1,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(client.nodes.byNum(100)?.deviceMetrics?.batteryLevel).toBe(80);
|
||||
expect(client.nodes.byNum(100)?.deviceMetrics?.voltage).toBeCloseTo(4.1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,14 @@ export class NodesClient {
|
||||
this.patch(packet.from, { position: packet.data });
|
||||
});
|
||||
|
||||
client.events.onTelemetryPacket.subscribe((packet) => {
|
||||
// Fold live device-metrics telemetry into the node so battery / channel
|
||||
// utilisation / voltage stay current between NodeInfo broadcasts.
|
||||
if (packet.data.variant.case === "deviceMetrics") {
|
||||
this.patch(packet.from, { deviceMetrics: packet.data.variant.value });
|
||||
}
|
||||
});
|
||||
|
||||
client.events.onMeshPacket.subscribe((packet) => {
|
||||
// Every inbound mesh packet refreshes lastHeard / snr for the
|
||||
// originating node — same semantics as the legacy nodeDB
|
||||
|
||||
57
playwright.config.ts
Normal file
57
playwright.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* End-to-end suite that drives the real web app against a REAL Meshtastic device
|
||||
* (a simulated `meshtasticd` node by default, or physical hardware) over the
|
||||
* HTTP(S) phone API, and exercises text messaging across a two-node mesh.
|
||||
*
|
||||
* Topology is brought up in e2e/global-setup.ts. The off-browser "mesh peer"
|
||||
* (e2e/peer/peer.py) drives/asserts the second node. See e2e/README.md.
|
||||
*/
|
||||
const WEB_PORT = Number(process.env.E2E_WEB_PORT ?? 3100);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/tests",
|
||||
outputDir: "./e2e/.results",
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
globalTeardown: "./e2e/global-teardown.ts",
|
||||
|
||||
// Tests share a single two-node mesh + one device identity, so run serially.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
reporter: [["list"], ["html", { outputFolder: "e2e/.report", open: "never" }]],
|
||||
|
||||
use: {
|
||||
baseURL: `http://localhost:${WEB_PORT}`,
|
||||
// The device webserver uses a self-signed cert (TLS-only on 9443).
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
// Belt-and-suspenders for the device's self-signed cert.
|
||||
launchOptions: { args: ["--ignore-certificate-errors"] },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: `pnpm --filter ./apps/web exec vite --port ${WEB_PORT} --strictPort`,
|
||||
url: `http://localhost:${WEB_PORT}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -27,6 +27,9 @@ importers:
|
||||
'@meshtastic/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:packages/sdk
|
||||
'@playwright/test':
|
||||
specifier: ^1.49.0
|
||||
version: 1.60.0
|
||||
'@types/node':
|
||||
specifier: ^25.9.3
|
||||
version: 25.9.3
|
||||
@@ -1717,6 +1720,11 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@preact/signals-core@1.14.2':
|
||||
resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==}
|
||||
|
||||
@@ -4376,6 +4384,11 @@ packages:
|
||||
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -5168,6 +5181,16 @@ packages:
|
||||
pkg-types@2.3.1:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
playwright-core@1.60.0:
|
||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.60.0:
|
||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
point-in-polygon-hao@1.2.4:
|
||||
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
|
||||
|
||||
@@ -7620,6 +7643,10 @@ snapshots:
|
||||
'@oxlint/binding-win32-x64-msvc@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
dependencies:
|
||||
playwright: 1.60.0
|
||||
|
||||
'@preact/signals-core@1.14.2': {}
|
||||
|
||||
'@publint/pack@0.1.4': {}
|
||||
@@ -10944,6 +10971,9 @@ snapshots:
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -11737,6 +11767,14 @@ snapshots:
|
||||
exsolve: 1.0.8
|
||||
pathe: 2.0.3
|
||||
|
||||
playwright-core@1.60.0: {}
|
||||
|
||||
playwright@1.60.0:
|
||||
dependencies:
|
||||
playwright-core: 1.60.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
point-in-polygon-hao@1.2.4:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.3
|
||||
|
||||
Reference in New Issue
Block a user