Files
web/e2e/global-setup.ts
Ben Meadors ad692eef74 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>
2026-06-20 11:19:50 -04:00

95 lines
3.1 KiB
TypeScript

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.");
}