import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts"; 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 { get: getArg, has: hasFlag } = createArgReader(); 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 = resolveGatewayUrl(urlRaw); 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 { request, waitOpen, close } = createGatewayWsClient({ url: url.toString() }); 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); close(); process.exit(2); } const healthRes = await request("health"); if (!healthRes.ok) { // eslint-disable-next-line no-console console.error("health failed:", healthRes.error); close(); 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); close(); 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?)"); close(); 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); close(); if (failed.length > 0) { process.exit(10); } } await main();