2026-02-15 03:33:33 +00:00
|
|
|
import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts";
|
2026-02-08 18:07:13 +01:00
|
|
|
|
|
|
|
|
type NodeListPayload = {
|
|
|
|
|
ts?: number;
|
|
|
|
|
nodes?: Array<{
|
|
|
|
|
nodeId: string;
|
|
|
|
|
displayName?: string;
|
|
|
|
|
platform?: string;
|
|
|
|
|
connected?: boolean;
|
|
|
|
|
paired?: boolean;
|
|
|
|
|
commands?: string[];
|
|
|
|
|
permissions?: unknown;
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type NodeListNode = NonNullable<NodeListPayload["nodes"]>[number];
|
|
|
|
|
|
2026-02-15 03:33:33 +00:00
|
|
|
const { get: getArg, has: hasFlag } = createArgReader();
|
2026-02-08 18:07:13 +01:00
|
|
|
|
|
|
|
|
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 <wss://host[:port]> --token <gateway.auth.token> [--node <id|name-substring>] [--dangerous] [--json]\n" +
|
|
|
|
|
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
|
|
|
|
|
);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 03:33:33 +00:00
|
|
|
const url = resolveGatewayUrl(urlRaw);
|
2026-02-08 18:07:13 +01:00
|
|
|
|
|
|
|
|
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() {
|
2026-02-15 03:33:33 +00:00
|
|
|
const { request, waitOpen, close } = createGatewayWsClient({ url: url.toString() });
|
2026-02-08 18:07:13 +01:00
|
|
|
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);
|
2026-02-15 03:33:33 +00:00
|
|
|
close();
|
2026-02-08 18:07:13 +01:00
|
|
|
process.exit(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const healthRes = await request("health");
|
|
|
|
|
if (!healthRes.ok) {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.error("health failed:", healthRes.error);
|
2026-02-15 03:33:33 +00:00
|
|
|
close();
|
2026-02-08 18:07:13 +01:00
|
|
|
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);
|
2026-02-15 03:33:33 +00:00
|
|
|
close();
|
2026-02-08 18:07:13 +01:00
|
|
|
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?)");
|
2026-02-15 03:33:33 +00:00
|
|
|
close();
|
2026-02-08 18:07:13 +01:00
|
|
|
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);
|
2026-02-15 03:33:33 +00:00
|
|
|
close();
|
2026-02-08 18:07:13 +01:00
|
|
|
|
|
|
|
|
if (failed.length > 0) {
|
|
|
|
|
process.exit(10);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await main();
|