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:
co-authored by
Dan Ditomaso
parent
a7aa25aa30
commit
ad692eef74
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user