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
+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 });
}
}