From 574e1e5925a104c6c1e34557c57599069294c2f2 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sat, 7 Feb 2026 22:59:28 +0100 Subject: [PATCH] Dev: add iOS node e2e scripts --- scripts/dev/gateway-smoke.ts | 163 ++++++++++ scripts/dev/ios-node-e2e.ts | 373 +++++++++++++++++++++++ scripts/dev/ios-pull-gateway-log.sh | 17 ++ scripts/dev/test-device-pair-telegram.ts | 62 ++++ 4 files changed, 615 insertions(+) create mode 100644 scripts/dev/gateway-smoke.ts create mode 100644 scripts/dev/ios-node-e2e.ts create mode 100755 scripts/dev/ios-pull-gateway-log.sh create mode 100644 scripts/dev/test-device-pair-telegram.ts diff --git a/scripts/dev/gateway-smoke.ts b/scripts/dev/gateway-smoke.ts new file mode 100644 index 00000000000..e217adf5eed --- /dev/null +++ b/scripts/dev/gateway-smoke.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import WebSocket from "ws"; + +type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown }; +type GatewayResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: unknown }; +type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown }; +type GatewayFrame = GatewayReqFrame | GatewayResFrame | GatewayEventFrame | { type: string }; + +const args = process.argv.slice(2); +const getArg = (flag: string) => { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +}; + +const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL; +const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN; + +if (!urlRaw || !token) { + // eslint-disable-next-line no-console + console.error( + "Usage: bun scripts/dev/gateway-smoke.ts --url --token \n" + + "Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN", + ); + process.exit(1); +} + +const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`); +if (!url.port) { + url.port = url.protocol === "wss:" ? "443" : "80"; +} + +const randomId = () => randomUUID(); + +async function main() { + const ws = new WebSocket(url.toString(), { handshakeTimeout: 8000 }); + const pending = new Map< + string, + { + resolve: (res: GatewayResFrame) => void; + reject: (err: Error) => void; + timeout: ReturnType; + } + >(); + + const request = (method: string, params?: unknown, timeoutMs = 12000) => + new Promise((resolve, reject) => { + const id = randomId(); + const frame: GatewayReqFrame = { type: "req", id, method, params }; + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`timeout waiting for ${method}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timeout }); + ws.send(JSON.stringify(frame)); + }); + + const waitOpen = () => + new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("ws open timeout")), 8000); + ws.once("open", () => { + clearTimeout(t); + resolve(); + }); + ws.once("error", (err) => { + clearTimeout(t); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + const toText = (data: WebSocket.RawData) => { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (Array.isArray(data)) { + return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); + } + return Buffer.from(data as Buffer).toString("utf8"); + }; + + ws.on("message", (data) => { + const text = toText(data); + let frame: GatewayFrame | null = null; + try { + frame = JSON.parse(text) as GatewayFrame; + } catch { + return; + } + if (!frame || typeof frame !== "object" || !("type" in frame)) { + return; + } + if (frame.type === "res") { + const res = frame as GatewayResFrame; + const waiter = pending.get(res.id); + if (waiter) { + pending.delete(res.id); + clearTimeout(waiter.timeout); + waiter.resolve(res); + } + return; + } + if (frame.type === "event") { + const evt = frame as GatewayEventFrame; + if (evt.event === "connect.challenge") { + return; + } + return; + } + }); + + await waitOpen(); + + // Match iOS "operator" session defaults: token auth, no device identity. + const connectRes = await request("connect", { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "openclaw-ios", + displayName: "openclaw gateway smoke test", + version: "dev", + platform: "dev", + mode: "ui", + instanceId: "openclaw-dev-smoke", + }, + locale: "en-US", + userAgent: "gateway-smoke", + role: "operator", + scopes: ["operator.read", "operator.write", "operator.admin"], + caps: [], + auth: { token }, + }); + + if (!connectRes.ok) { + // eslint-disable-next-line no-console + console.error("connect failed:", connectRes.error); + process.exit(2); + } + + const healthRes = await request("health"); + if (!healthRes.ok) { + // eslint-disable-next-line no-console + console.error("health failed:", healthRes.error); + process.exit(3); + } + + const historyRes = await request("chat.history", { sessionKey: "main" }, 15000); + if (!historyRes.ok) { + // eslint-disable-next-line no-console + console.error("chat.history failed:", historyRes.error); + process.exit(4); + } + + // eslint-disable-next-line no-console + console.log("ok: connected + health + chat.history"); + ws.close(); +} + +await main(); diff --git a/scripts/dev/ios-node-e2e.ts b/scripts/dev/ios-node-e2e.ts new file mode 100644 index 00000000000..7b64b6e2d61 --- /dev/null +++ b/scripts/dev/ios-node-e2e.ts @@ -0,0 +1,373 @@ +import { randomUUID } from "node:crypto"; +import WebSocket from "ws"; + +type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown }; +type GatewayResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: unknown }; +type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown }; +type GatewayFrame = GatewayReqFrame | GatewayResFrame | GatewayEventFrame | { type: string }; + +type NodeListPayload = { + ts?: number; + nodes?: Array<{ + nodeId: string; + displayName?: string; + platform?: string; + connected?: boolean; + paired?: boolean; + commands?: string[]; + permissions?: unknown; + }>; +}; + +type NodeListNode = NonNullable[number]; + +const args = process.argv.slice(2); +const getArg = (flag: string) => { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +}; + +const hasFlag = (flag: string) => args.includes(flag); + +const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL; +const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN; +const nodeHint = getArg("--node"); +const dangerous = hasFlag("--dangerous") || process.env.OPENCLAW_RUN_DANGEROUS === "1"; +const jsonOut = hasFlag("--json"); + +if (!urlRaw || !token) { + // eslint-disable-next-line no-console + console.error( + "Usage: bun scripts/dev/ios-node-e2e.ts --url --token [--node ] [--dangerous] [--json]\n" + + "Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN", + ); + process.exit(1); +} + +const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`); +if (!url.port) { + url.port = url.protocol === "wss:" ? "443" : "80"; +} + +const randomId = () => randomUUID(); + +const isoNow = () => new Date().toISOString(); +const isoMinusMs = (ms: number) => new Date(Date.now() - ms).toISOString(); + +type TestCase = { + id: string; + command: string; + params?: unknown; + timeoutMs?: number; + dangerous?: boolean; +}; + +function formatErr(err: unknown): string { + if (!err) { + return "error"; + } + if (typeof err === "string") { + return err; + } + if (err instanceof Error) { + return err.message || String(err); + } + try { + return JSON.stringify(err); + } catch { + return Object.prototype.toString.call(err); + } +} + +function pickIosNode(list: NodeListPayload, hint?: string): NodeListNode | null { + const nodes = (list.nodes ?? []).filter((n) => n && n.connected); + const ios = nodes.filter((n) => (n.platform ?? "").toLowerCase().includes("ios")); + if (ios.length === 0) { + return null; + } + if (!hint) { + return ios[0] ?? null; + } + const h = hint.toLowerCase(); + return ( + ios.find((n) => n.nodeId.toLowerCase() === h) ?? + ios.find((n) => (n.displayName ?? "").toLowerCase().includes(h)) ?? + ios.find((n) => n.nodeId.toLowerCase().includes(h)) ?? + ios[0] ?? + null + ); +} + +async function main() { + const ws = new WebSocket(url.toString(), { handshakeTimeout: 8000 }); + const pending = new Map< + string, + { + resolve: (res: GatewayResFrame) => void; + reject: (err: Error) => void; + timeout: ReturnType; + } + >(); + + const request = (method: string, params?: unknown, timeoutMs = 12_000) => + new Promise((resolve, reject) => { + const id = randomId(); + const frame: GatewayReqFrame = { type: "req", id, method, params }; + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`timeout waiting for ${method}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timeout }); + ws.send(JSON.stringify(frame)); + }); + + const waitOpen = () => + new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("ws open timeout")), 8000); + ws.once("open", () => { + clearTimeout(t); + resolve(); + }); + ws.once("error", (err) => { + clearTimeout(t); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + const toText = (data: WebSocket.RawData) => { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (Array.isArray(data)) { + return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); + } + return Buffer.from(data as Buffer).toString("utf8"); + }; + + ws.on("message", (data) => { + const text = toText(data); + let frame: GatewayFrame | null = null; + try { + frame = JSON.parse(text) as GatewayFrame; + } catch { + return; + } + if (!frame || typeof frame !== "object" || !("type" in frame)) { + return; + } + if (frame.type === "res") { + const res = frame as GatewayResFrame; + const waiter = pending.get(res.id); + if (waiter) { + pending.delete(res.id); + clearTimeout(waiter.timeout); + waiter.resolve(res); + } + return; + } + if (frame.type === "event") { + // Ignore; caller can extend to watch node.pair.* etc. + return; + } + }); + + await waitOpen(); + + const connectRes = await request("connect", { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "cli", + displayName: "openclaw ios node e2e", + version: "dev", + platform: "dev", + mode: "cli", + instanceId: "openclaw-dev-ios-node-e2e", + }, + locale: "en-US", + userAgent: "ios-node-e2e", + role: "operator", + scopes: ["operator.read", "operator.write", "operator.admin"], + caps: [], + auth: { token }, + }); + + if (!connectRes.ok) { + // eslint-disable-next-line no-console + console.error("connect failed:", connectRes.error); + process.exit(2); + } + + const healthRes = await request("health"); + if (!healthRes.ok) { + // eslint-disable-next-line no-console + console.error("health failed:", healthRes.error); + process.exit(3); + } + + const nodesRes = await request("node.list"); + if (!nodesRes.ok) { + // eslint-disable-next-line no-console + console.error("node.list failed:", nodesRes.error); + process.exit(4); + } + + const listPayload = (nodesRes.payload ?? {}) as NodeListPayload; + let node = pickIosNode(listPayload, nodeHint); + if (!node) { + const waitSeconds = Number.parseInt(getArg("--wait-seconds") ?? "25", 10); + const deadline = Date.now() + Math.max(1, waitSeconds) * 1000; + while (!node && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 1000)); + const res = await request("node.list").catch(() => null); + if (!res?.ok) { + continue; + } + node = pickIosNode((res.payload ?? {}) as NodeListPayload, nodeHint); + } + } + if (!node) { + // eslint-disable-next-line no-console + console.error("No connected iOS nodes found. (Is the iOS app connected to the gateway?)"); + process.exit(5); + } + + const tests: TestCase[] = [ + { id: "device.info", command: "device.info" }, + { id: "device.status", command: "device.status" }, + { + id: "system.notify", + command: "system.notify", + params: { title: "OpenClaw E2E", body: `ios-node-e2e @ ${isoNow()}`, delivery: "system" }, + }, + { + id: "contacts.search", + command: "contacts.search", + params: { query: null, limit: 5 }, + }, + { + id: "calendar.events", + command: "calendar.events", + params: { startISO: isoMinusMs(6 * 60 * 60 * 1000), endISO: isoNow(), limit: 10 }, + }, + { + id: "reminders.list", + command: "reminders.list", + params: { status: "incomplete", limit: 10 }, + }, + { + id: "motion.pedometer", + command: "motion.pedometer", + params: { startISO: isoMinusMs(60 * 60 * 1000), endISO: isoNow() }, + }, + { + id: "photos.latest", + command: "photos.latest", + params: { limit: 1, maxWidth: 512, quality: 0.7 }, + }, + { + id: "camera.snap", + command: "camera.snap", + params: { facing: "back", maxWidth: 768, quality: 0.7, format: "jpeg" }, + dangerous: true, + timeoutMs: 20_000, + }, + { + id: "screen.record", + command: "screen.record", + params: { durationMs: 2_000, fps: 15, includeAudio: false }, + dangerous: true, + timeoutMs: 30_000, + }, + ]; + + const run = tests.filter((t) => dangerous || !t.dangerous); + + const results: Array<{ + id: string; + ok: boolean; + error?: unknown; + payload?: unknown; + }> = []; + + for (const t of run) { + const invokeRes = await request( + "node.invoke", + { + nodeId: node.nodeId, + command: t.command, + params: t.params, + timeoutMs: t.timeoutMs ?? 12_000, + idempotencyKey: randomUUID(), + }, + (t.timeoutMs ?? 12_000) + 2_000, + ).catch((err) => { + results.push({ id: t.id, ok: false, error: formatErr(err) }); + return null; + }); + + if (!invokeRes) { + continue; + } + + if (!invokeRes.ok) { + results.push({ id: t.id, ok: false, error: invokeRes.error }); + continue; + } + + results.push({ id: t.id, ok: true, payload: invokeRes.payload }); + } + + if (jsonOut) { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + { + gateway: url.toString(), + node: { + nodeId: node.nodeId, + displayName: node.displayName, + platform: node.platform, + }, + dangerous, + results, + }, + null, + 2, + ), + ); + } else { + const pad = (s: string, n: number) => (s.length >= n ? s : s + " ".repeat(n - s.length)); + const rows = results.map((r) => ({ + cmd: r.id, + ok: r.ok ? "ok" : "fail", + note: r.ok ? "" : formatErr(r.error ?? "error"), + })); + const width = Math.min(64, Math.max(12, ...rows.map((r) => r.cmd.length))); + // eslint-disable-next-line no-console + console.log(`node: ${node.displayName ?? node.nodeId} (${node.platform ?? "unknown"})`); + // eslint-disable-next-line no-console + console.log(`dangerous: ${dangerous ? "on" : "off"}`); + // eslint-disable-next-line no-console + console.log(""); + for (const r of rows) { + // eslint-disable-next-line no-console + console.log(`${pad(r.cmd, width)} ${pad(r.ok, 4)} ${r.note}`); + } + } + + const failed = results.filter((r) => !r.ok); + ws.close(); + + if (failed.length > 0) { + process.exit(10); + } +} + +await main(); diff --git a/scripts/dev/ios-pull-gateway-log.sh b/scripts/dev/ios-pull-gateway-log.sh new file mode 100755 index 00000000000..3fa6dbe1864 --- /dev/null +++ b/scripts/dev/ios-pull-gateway-log.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE_UDID="${1:-00008130-000630CE0146001C}" +BUNDLE_ID="${2:-ai.openclaw.ios.dev.mariano.test}" +DEST="${3:-/tmp/openclaw-gateway.log}" + +xcrun devicectl device copy from \ + --device "$DEVICE_UDID" \ + --domain-type appDataContainer \ + --domain-identifier "$BUNDLE_ID" \ + --source Documents/openclaw-gateway.log \ + --destination "$DEST" >/dev/null + +echo "Pulled to: $DEST" +tail -n 200 "$DEST" + diff --git a/scripts/dev/test-device-pair-telegram.ts b/scripts/dev/test-device-pair-telegram.ts new file mode 100644 index 00000000000..e33a060ecd4 --- /dev/null +++ b/scripts/dev/test-device-pair-telegram.ts @@ -0,0 +1,62 @@ +import { loadConfig } from "../../src/config/config.js"; +import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js"; +import { loadOpenClawPlugins } from "../../src/plugins/loader.js"; +import { sendMessageTelegram } from "../../src/telegram/send.js"; + +const args = process.argv.slice(2); +const getArg = (flag: string, short?: string) => { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + if (short) { + const sidx = args.indexOf(short); + if (sidx !== -1 && sidx + 1 < args.length) { + return args[sidx + 1]; + } + } + return undefined; +}; + +const chatId = getArg("--chat", "-c"); +const accountId = getArg("--account", "-a"); +if (!chatId) { + // eslint-disable-next-line no-console + console.error( + "Usage: bun scripts/dev/test-device-pair-telegram.ts --chat [--account ]", + ); + process.exit(1); +} + +const cfg = loadConfig(); +loadOpenClawPlugins({ config: cfg }); + +const match = matchPluginCommand("/pair"); +if (!match) { + // eslint-disable-next-line no-console + console.error("/pair plugin command not registered."); + process.exit(1); +} + +const result = await executePluginCommand({ + command: match.command, + args: match.args, + senderId: chatId, + channel: "telegram", + channelId: "telegram", + isAuthorizedSender: true, + commandBody: "/pair", + config: cfg, + from: `telegram:${chatId}`, + to: `telegram:${chatId}`, + accountId: accountId, +}); + +if (result.text) { + await sendMessageTelegram(chatId, result.text, { + accountId: accountId, + }); +} + +// eslint-disable-next-line no-console +console.log("Sent split /pair messages to", chatId, accountId ? `(${accountId})` : "");