From 607bbe4f5cb9c7af2cd6c50137caefc094158e7d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 7 Jun 2026 08:36:06 +0200 Subject: [PATCH] fix(dev): validate ios node smoke payloads --- scripts/dev/ios-node-e2e.ts | 54 ++++++ test/scripts/ios-node-e2e.test.ts | 288 ++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 test/scripts/ios-node-e2e.test.ts diff --git a/scripts/dev/ios-node-e2e.ts b/scripts/dev/ios-node-e2e.ts index 0f75419025b..e1e307deded 100644 --- a/scripts/dev/ios-node-e2e.ts +++ b/scripts/dev/ios-node-e2e.ts @@ -1,4 +1,5 @@ // Ios Node E2E script supports OpenClaw repository automation. +import { randomUUID } from "node:crypto"; import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, @@ -78,6 +79,52 @@ function formatErr(err: unknown): string { } } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function payloadShapeError(command: string, payload: unknown): string | null { + if (payload == null) { + return `${command} returned no payload`; + } + if (Array.isArray(payload)) { + return `${command} returned an array payload`; + } + if (!isRecord(payload)) { + return `${command} returned a ${typeof payload} payload`; + } + if (Object.keys(payload).length === 0) { + return `${command} returned an empty object payload`; + } + if (command === "device.info") { + const hasSystemName = + typeof payload.systemName === "string" && payload.systemName.trim().length > 0; + const hasSystemVersion = + typeof payload.systemVersion === "string" && payload.systemVersion.trim().length > 0; + if (!hasSystemName || !hasSystemVersion) { + return "device.info payload missing systemName/systemVersion"; + } + } + return null; +} + +function commandPayloadFromInvokePayload(payload: unknown): unknown { + if (!isRecord(payload)) { + return payload; + } + if (typeof payload.payloadJSON === "string") { + try { + return JSON.parse(payload.payloadJSON); + } catch { + return undefined; + } + } + if ("payload" in payload && ("ok" in payload || "nodeId" in payload || "command" in payload)) { + return commandPayloadFromInvokePayload(payload.payload); + } + return payload; +} + 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")); @@ -245,6 +292,13 @@ async function main() { continue; } + const commandPayload = commandPayloadFromInvokePayload(invokeRes.payload); + const payloadError = payloadShapeError(t.command, commandPayload); + if (payloadError) { + results.push({ id: t.id, ok: false, error: payloadError, payload: invokeRes.payload }); + continue; + } + results.push({ id: t.id, ok: true, payload: invokeRes.payload }); } diff --git a/test/scripts/ios-node-e2e.test.ts b/test/scripts/ios-node-e2e.test.ts new file mode 100644 index 00000000000..0607cc72fdf --- /dev/null +++ b/test/scripts/ios-node-e2e.test.ts @@ -0,0 +1,288 @@ +// Ios Node E2E tests cover the dev iOS node smoke script. +import { spawn } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; + +type ScriptResult = { + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + timedOut: boolean; +}; + +type GatewayFrame = { + id: string; + method: string; + params?: { + command?: string; + idempotencyKey?: string; + }; + type: string; +}; + +let server: Server | undefined; +let wss: WebSocketServer | undefined; + +afterEach(async () => { + await new Promise((resolve) => { + wss?.close(() => resolve()); + if (!wss) { + resolve(); + } + }); + wss = undefined; + + await new Promise((resolve, reject) => { + server?.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + if (!server) { + resolve(); + } + }); + server = undefined; +}); + +function invokePayload( + command: string, + mode: "empty" | "invalid-payload-json" | "primitive-device-info" | "valid", +): unknown { + if (mode === "primitive-device-info" && command === "device.info") { + return "ok"; + } + if (mode === "invalid-payload-json" && command === "device.status") { + return { payloadJSON: "{" }; + } + if (mode === "empty") { + return {}; + } + switch (command) { + case "device.info": + return { systemName: "iOS", systemVersion: "18.0" }; + case "device.status": + return { battery: { state: "charging" } }; + case "system.notify": + return { delivered: true }; + case "contacts.search": + return { contacts: [] }; + case "calendar.events": + return { events: [] }; + case "reminders.list": + return { reminders: [] }; + case "motion.pedometer": + return { steps: 12 }; + case "photos.latest": + return { photos: [] }; + default: + return { ok: true }; + } +} + +async function listenGateway(params: { + mode: "empty" | "invalid-payload-json" | "primitive-device-info" | "valid"; + invokeParams: Array<{ command?: string; idempotencyKey?: string }>; +}): Promise { + server = createServer(); + wss = new WebSocketServer({ server }); + wss.on("connection", (ws: WebSocket) => { + ws.on("message", (data) => { + const frame = JSON.parse(String(data)) as GatewayFrame; + if (frame.type !== "req") { + return; + } + if (frame.method === "connect") { + ws.send( + JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { connected: true } }), + ); + return; + } + if (frame.method === "health") { + ws.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { status: "ok" } })); + return; + } + if (frame.method === "node.list") { + ws.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + nodes: [ + { + nodeId: "ios-node", + displayName: "iPhone", + platform: "iOS", + connected: true, + }, + ], + }, + }), + ); + return; + } + if (frame.method === "node.invoke") { + params.invokeParams.push(frame.params ?? {}); + ws.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + ok: true, + nodeId: "ios-node", + command: frame.params?.command, + payload: invokePayload(String(frame.params?.command ?? ""), params.mode), + }, + }), + ); + return; + } + ws.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: `unexpected method ${frame.method}`, + }), + ); + }); + }); + await new Promise((resolve) => { + server?.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test websocket server did not get a TCP address"); + } + return `ws://127.0.0.1:${address.port}`; +} + +function runScript(url: string): Promise { + return new Promise((resolve) => { + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "scripts/dev/ios-node-e2e.ts", + "--url", + url, + "--token", + "token", + "--json", + ], + { stdio: "pipe" }, + ); + let stdout = ""; + let stderr = ""; + let settled = false; + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + child.kill("SIGKILL"); + resolve({ status: null, signal: "SIGKILL", stdout, stderr, timedOut: true }); + }, 5000); + timeout.unref?.(); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("close", (status, signal) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve({ status, signal, stdout, stderr, timedOut: false }); + }); + }); +} + +describe("ios-node-e2e", () => { + it("fails empty node invoke payloads instead of counting them as proof", async () => { + const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = []; + const url = await listenGateway({ mode: "empty", invokeParams }); + const result = await runScript(url); + const report = JSON.parse(result.stdout) as { + results: Array<{ error?: string; id: string; ok: boolean; payload?: unknown }>; + }; + + expect(result).toMatchObject({ signal: null, status: 10, timedOut: false }); + expect(report.results[0]).toMatchObject({ + error: "device.info returned an empty object payload", + id: "device.info", + ok: false, + payload: { + ok: true, + payload: {}, + }, + }); + expect(report.results.every((entry) => entry.ok === false)).toBe(true); + expect(invokeParams.length).toBeGreaterThan(0); + }); + + it("fails malformed primitive device info payloads", async () => { + const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = []; + const url = await listenGateway({ mode: "primitive-device-info", invokeParams }); + const result = await runScript(url); + const report = JSON.parse(result.stdout) as { + results: Array<{ error?: string; id: string; ok: boolean; payload?: unknown }>; + }; + + expect(result).toMatchObject({ signal: null, status: 10, timedOut: false }); + expect(report.results[0]).toMatchObject({ + error: "device.info returned a string payload", + id: "device.info", + ok: false, + }); + }); + + it("fails malformed nested payloadJSON payloads", async () => { + const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = []; + const url = await listenGateway({ mode: "invalid-payload-json", invokeParams }); + const result = await runScript(url); + const report = JSON.parse(result.stdout) as { + results: Array<{ error?: string; id: string; ok: boolean; payload?: unknown }>; + }; + + expect(result).toMatchObject({ signal: null, status: 10, timedOut: false }); + expect(report.results[1]).toMatchObject({ + error: "device.status returned no payload", + id: "device.status", + ok: false, + }); + }); + + it("accepts non-empty node invoke payloads and sends idempotency keys", async () => { + const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = []; + const url = await listenGateway({ mode: "valid", invokeParams }); + const result = await runScript(url); + const report = JSON.parse(result.stdout) as { + results: Array<{ id: string; ok: boolean }>; + }; + + expect(result).toMatchObject({ signal: null, status: 0, timedOut: false }); + expect(report.results.every((entry) => entry.ok)).toBe(true); + expect(invokeParams.map((params) => params.command)).toEqual([ + "device.info", + "device.status", + "system.notify", + "contacts.search", + "calendar.events", + "reminders.list", + "motion.pedometer", + "photos.latest", + ]); + expect(invokeParams.every((params) => typeof params.idempotencyKey === "string")).toBe(true); + }); +});