diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..a701e793 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/apps/web/src/core/subscriptions.ts b/apps/web/src/core/subscriptions.ts index dd3db72f..f7375c08 100644 --- a/apps/web/src/core/subscriptions.ts +++ b/apps/web/src/core/subscriptions.ts @@ -37,17 +37,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); }); @@ -61,8 +50,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); diff --git a/apps/web/src/pages/Connections/useConnections.ts b/apps/web/src/pages/Connections/useConnections.ts index 0d8f45f3..865e5e2b 100644 --- a/apps/web/src/pages/Connections/useConnections.ts +++ b/apps/web/src/pages/Connections/useConnections.ts @@ -233,7 +233,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; @@ -271,7 +274,7 @@ export function useConnections() { return false; } }, - [connections, updateStatus, setupMeshDevice], + [updateStatus, setupMeshDevice], ); const disconnect = useCallback( diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..b4e2ebfe --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,7 @@ +# Playwright run artifacts +.results/ +.report/ + +# Python mesh-peer virtualenv + caches +peer/.venv/ +peer/__pycache__/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..47b1fcd1 --- /dev/null +++ b/e2e/README.md @@ -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:// \ +E2E_PEER_HOST= 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. diff --git a/e2e/device/docker-compose.yml b/e2e/device/docker-compose.yml new file mode 100644 index 00000000..65d81ec3 --- /dev/null +++ b/e2e/device/docker-compose.yml @@ -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 diff --git a/e2e/device/nodeA.yaml b/e2e/device/nodeA.yaml new file mode 100644 index 00000000..ba4f94b1 --- /dev/null +++ b/e2e/device/nodeA.yaml @@ -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 diff --git a/e2e/device/nodeB.yaml b/e2e/device/nodeB.yaml new file mode 100644 index 00000000..60dd8c2a --- /dev/null +++ b/e2e/device/nodeB.yaml @@ -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 diff --git a/e2e/fixtures/peer.ts b/e2e/fixtures/peer.ts new file mode 100644 index 00000000..ca512bc3 --- /dev/null +++ b/e2e/fixtures/peer.ts @@ -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 { + 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((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 { + 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((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; + /** 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 { + 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((res, rej) => { + resolveReceived = res; + rejectReceived = rej; + }); + const handle: RecvHandle = { received, stop: () => child.kill() }; + + return new Promise((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); + }); + }); +} diff --git a/e2e/fixtures/test.ts b/e2e/fixtures/test.ts new file mode 100644 index 00000000..2dc9e65f --- /dev/null +++ b/e2e/fixtures/test.ts @@ -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({ + 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 }; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..7fe64248 --- /dev/null +++ b/e2e/global-setup.ts @@ -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 { + const deadline = Date.now() + timeoutMs; + let lastErr = "connection refused"; + while (Date.now() < deadline) { + const ok = await new Promise((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 { + const deadline = Date.now() + timeoutMs; + let lastErr = "connection refused"; + while (Date.now() < deadline) { + const ok = await new Promise((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 { + 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."); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 00000000..5a91feff --- /dev/null +++ b/e2e/global-teardown.ts @@ -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 { + 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)."); + } +} diff --git a/e2e/pages/ConnectionPage.ts b/e2e/pages/ConnectionPage.ts new file mode 100644 index 00000000..95ba9d31 --- /dev/null +++ b/e2e/pages/ConnectionPage.ts @@ -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 { + 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 }); + } +} diff --git a/e2e/pages/MessagesPage.ts b/e2e/pages/MessagesPage.ts new file mode 100644 index 00000000..25626e52 --- /dev/null +++ b/e2e/pages/MessagesPage.ts @@ -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
  • ) 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 { + 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 { + 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 { + await this.input().fill(text); + await this.sendButton().click(); + } + + async expectMessage(text: string, timeout = 45_000): Promise { + await expect(this.message(text).first()).toBeVisible({ timeout }); + } +} diff --git a/e2e/peer/peer.py b/e2e/peer/peer.py new file mode 100644 index 00000000..3c12b900 --- /dev/null +++ b/e2e/peer/peer.py @@ -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=` + - `recv` -> `READY` once subscribed, then `RECEIVED=` on match + +Exit codes: 0 success, 1 timeout / not received, 2 usage / connection error. + +Usage: + peer.py --host localhost --port 4404 send "hello" [--to ] [--want-ack] + peer.py --host localhost --port 4403 recv "hello" [--from-node ] [--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()) diff --git a/e2e/peer/requirements.txt b/e2e/peer/requirements.txt new file mode 100644 index 00000000..97e1665a --- /dev/null +++ b/e2e/peer/requirements.txt @@ -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 diff --git a/e2e/tests/connect.spec.ts b/e2e/tests/connect.spec.ts new file mode 100644 index 00000000..20a99858 --- /dev/null +++ b/e2e/tests/connect.spec.ts @@ -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/); +}); diff --git a/e2e/tests/messaging.broadcast.spec.ts b/e2e/tests/messaging.broadcast.spec.ts new file mode 100644 index 00000000..f17724e6 --- /dev/null +++ b/e2e/tests/messaging.broadcast.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/messaging.direct.spec.ts b/e2e/tests/messaging.direct.spec.ts new file mode 100644 index 00000000..af3e9aa7 --- /dev/null +++ b/e2e/tests/messaging.direct.spec.ts @@ -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); + }, + ); +}); diff --git a/package.json b/package.json index 0e837538..7a2794a1 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,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": { @@ -41,6 +44,7 @@ "devDependencies": { "@meshtastic/core": "jsr:^2.6.6", "@meshtastic/sdk": "workspace:*", + "@playwright/test": "^1.49.0", "@types/node": "^24.3.1", "husky": "^9.1.0", "lint-staged": "^16.0.0", diff --git a/packages/sdk/src/features/nodes/NodesClient.test.ts b/packages/sdk/src/features/nodes/NodesClient.test.ts index bb37d4a4..4aaca5a3 100644 --- a/packages/sdk/src/features/nodes/NodesClient.test.ts +++ b/packages/sdk/src/features/nodes/NodesClient.test.ts @@ -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 () => { @@ -25,4 +26,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); + }); }); diff --git a/packages/sdk/src/features/nodes/NodesClient.ts b/packages/sdk/src/features/nodes/NodesClient.ts index 9ef7e5d3..597dcbd5 100644 --- a/packages/sdk/src/features/nodes/NodesClient.ts +++ b/packages/sdk/src/features/nodes/NodesClient.ts @@ -52,6 +52,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 diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..10a6fa90 --- /dev/null +++ b/playwright.config.ts @@ -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", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0805dfe7..629fa655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: ^24.3.1 version: 24.12.0 @@ -1605,6 +1608,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==} @@ -4441,6 +4449,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} @@ -5270,6 +5283,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + 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==} @@ -7651,6 +7674,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.69.0': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@preact/signals-core@1.14.2': {} '@publint/pack@0.1.4': {} @@ -11593,6 +11620,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12412,6 +12442,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.2