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 10:19:50 -05:00
committed by GitHub
parent a7aa25aa30
commit ad692eef74
24 changed files with 1028 additions and 15 deletions

66
.github/workflows/e2e.yml vendored Normal file
View 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

View File

@@ -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);

View File

@@ -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
View 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
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.

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
e2e/device/nodeA.yaml Normal file
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
e2e/device/nodeB.yaml Normal file
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
e2e/fixtures/peer.ts Normal file
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
e2e/fixtures/test.ts Normal file
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
e2e/global-setup.ts Normal file
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
e2e/global-teardown.ts Normal file
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).");
}
}

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
e2e/pages/MessagesPage.ts Normal file
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
e2e/peer/peer.py Normal file
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())

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
e2e/tests/connect.spec.ts Normal file
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/);
});

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

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

View File

@@ -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",

View File

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

View File

@@ -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
View 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
View File

@@ -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