import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { loadConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { parseNodeList, parsePairingList } from "../shared/node-list-parse.js"; import type { NodeListNode } from "../shared/node-list-types.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { buildGatewayConnectionDetails } from "./call.js"; import { GatewayClient } from "./client.js"; import { resolveGatewayCredentialsFromConfig } from "./credentials.js"; const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); const LIVE_ANDROID_NODE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ANDROID_NODE); const describeLive = LIVE && LIVE_ANDROID_NODE ? describe : describe.skip; const SKIPPED_INTERACTIVE_COMMANDS = new Set(["screen.record"]); type CommandOutcome = "success" | "error"; type CommandContext = { notifications: Array>; }; type CommandProfile = { buildParams: (ctx: CommandContext) => Record; timeoutMs?: number; outcome: CommandOutcome; allowedErrorCodes?: string[]; onSuccess?: (payload: unknown, ctx: CommandContext) => void; }; type CommandResult = { command: string; ok: boolean; payload?: unknown; errorCode?: string; errorMessage?: string; durationMs: number; }; function asRecord(value: unknown): Record { return typeof value === "object" && value !== null ? (value as Record) : {}; } function readString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value .map((entry) => (typeof entry === "string" ? entry.trim() : "")) .filter((entry) => entry.length > 0); } function parseErrorCode(message: string): string { const trimmed = message.trim(); const idx = trimmed.indexOf(":"); const head = (idx >= 0 ? trimmed.slice(0, idx) : trimmed).trim(); if (/^[A-Z0-9_]+$/.test(head)) { return head; } return "UNKNOWN"; } function assertObjectPayload(command: string, payload: unknown): Record { const obj = asRecord(payload); expect(Object.keys(obj).length, `${command} payload must be a JSON object`).toBeGreaterThan(0); return obj; } const COMMAND_PROFILES: Record = { "canvas.present": { buildParams: () => ({ url: "about:blank" }), timeoutMs: 20_000, outcome: "success", }, "canvas.hide": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", }, "canvas.navigate": { buildParams: () => ({ url: "about:blank" }), timeoutMs: 20_000, outcome: "success", }, "canvas.eval": { buildParams: () => ({ javaScript: "1 + 1" }), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("canvas.eval", payload); expect(obj.result).toBeDefined(); }, }, "canvas.snapshot": { buildParams: () => ({ format: "jpeg", maxWidth: 320, quality: 0.6 }), timeoutMs: 30_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("canvas.snapshot", payload); expect(readString(obj.format)).not.toBeNull(); expect(readString(obj.base64)).not.toBeNull(); }, }, "canvas.a2ui.push": { buildParams: () => ({ jsonl: '{"beginRendering":{}}\n' }), timeoutMs: 30_000, outcome: "success", }, "canvas.a2ui.pushJSONL": { buildParams: () => ({ jsonl: '{"beginRendering":{}}\n' }), timeoutMs: 30_000, outcome: "success", }, "canvas.a2ui.reset": { buildParams: () => ({}), timeoutMs: 30_000, outcome: "success", }, "screen.record": { buildParams: () => ({ durationMs: 1500, fps: 8, includeAudio: false }), timeoutMs: 60_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("screen.record", payload); expect(readString(obj.base64)).not.toBeNull(); }, }, "camera.list": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("camera.list", payload); expect(Array.isArray(obj.devices)).toBe(true); }, }, "camera.snap": { buildParams: () => ({ facing: "front", maxWidth: 640, quality: 0.6, format: "jpg" }), timeoutMs: 60_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("camera.snap", payload); expect(readString(obj.base64)).not.toBeNull(); }, }, "camera.clip": { buildParams: () => ({ facing: "front", durationMs: 1500, includeAudio: false, format: "mp4" }), timeoutMs: 90_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("camera.clip", payload); expect(readString(obj.base64)).not.toBeNull(); }, }, "location.get": { buildParams: () => ({ timeoutMs: 5000, desiredAccuracy: "balanced" }), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { assertObjectPayload("location.get", payload); }, }, "device.status": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { assertObjectPayload("device.status", payload); }, }, "device.info": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("device.info", payload); expect(readString(obj.systemName)).not.toBeNull(); expect(readString(obj.systemVersion)).not.toBeNull(); }, }, "device.permissions": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("device.permissions", payload); expect(asRecord(obj.permissions)).toBeTruthy(); }, }, "device.health": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("device.health", payload); expect(asRecord(obj.memory)).toBeTruthy(); }, }, "notifications.list": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload, ctx) => { const obj = assertObjectPayload("notifications.list", payload); const notifications = Array.isArray(obj.notifications) ? obj.notifications : []; ctx.notifications = notifications.map((entry) => asRecord(entry)); }, }, "notifications.actions": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "error", allowedErrorCodes: ["INVALID_REQUEST"], }, "sms.send": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "error", allowedErrorCodes: ["INVALID_REQUEST"], }, "debug.logs": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("debug.logs", payload); expect(readString(obj.logs)).not.toBeNull(); }, }, "debug.ed25519": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("debug.ed25519", payload); expect(readString(obj.diagnostics)).not.toBeNull(); }, }, "app.update": { buildParams: () => ({}), timeoutMs: 20_000, outcome: "error", allowedErrorCodes: ["INVALID_REQUEST"], }, }; function resolveGatewayConnection() { const cfg = loadConfig(); const urlOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_URL); const details = buildGatewayConnectionDetails({ config: cfg, ...(urlOverride ? { url: urlOverride } : {}), }); const tokenOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_TOKEN); const passwordOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_PASSWORD); const creds = resolveGatewayCredentialsFromConfig({ cfg, explicitAuth: { ...(tokenOverride ? { token: tokenOverride } : {}), ...(passwordOverride ? { password: passwordOverride } : {}), }, }); return { url: details.url, token: creds.token, password: creds.password, }; } async function connectGatewayClient(params: { url: string; token?: string; password?: string; }): Promise { return await new Promise((resolve, reject) => { let settled = false; const stop = (err?: Error, client?: GatewayClient) => { if (settled) { return; } settled = true; clearTimeout(timer); if (err) { reject(err); return; } resolve(client as GatewayClient); }; const client = new GatewayClient({ url: params.url, token: params.token, password: params.password, connectDelayMs: 0, clientName: GATEWAY_CLIENT_NAMES.TEST, clientDisplayName: "android-live-test", clientVersion: "dev", platform: "test", mode: GATEWAY_CLIENT_MODES.TEST, onHelloOk: () => stop(undefined, client), onConnectError: (err) => stop(err), onClose: (code, reason) => stop(new Error(`gateway closed during connect (${code}): ${reason}`)), }); const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000); timer.unref(); client.start(); }); } function isAndroidNode(node: NodeListNode): boolean { const platform = readString(node.platform)?.toLowerCase(); if (platform === "android") { return true; } const displayName = readString(node.displayName)?.toLowerCase(); return displayName?.includes("android") === true; } function selectTargetNode(nodes: NodeListNode[]): NodeListNode { const nodeIdOverride = readString(process.env.OPENCLAW_ANDROID_NODE_ID); if (nodeIdOverride) { const match = nodes.find((node) => node.nodeId === nodeIdOverride); if (!match) { throw new Error(`OPENCLAW_ANDROID_NODE_ID not found in node.list: ${nodeIdOverride}`); } return match; } const nodeNameOverride = readString(process.env.OPENCLAW_ANDROID_NODE_NAME)?.toLowerCase(); if (nodeNameOverride) { const match = nodes.find( (node) => readString(node.displayName)?.toLowerCase() === nodeNameOverride, ); if (!match) { throw new Error(`OPENCLAW_ANDROID_NODE_NAME not found in node.list: ${nodeNameOverride}`); } return match; } const androidNodes = nodes.filter(isAndroidNode); if (androidNodes.length === 0) { throw new Error("no Android node found in node.list"); } return androidNodes.slice().toSorted((a, b) => { const aMs = typeof a.connectedAtMs === "number" ? a.connectedAtMs : 0; const bMs = typeof b.connectedAtMs === "number" ? b.connectedAtMs : 0; return bMs - aMs; })[0]; } async function invokeNodeCommand(params: { client: GatewayClient; nodeId: string; command: string; profile: CommandProfile; ctx: CommandContext; }): Promise { const startedAt = Date.now(); const timeoutMs = params.profile.timeoutMs ?? 20_000; const invokeParams = { nodeId: params.nodeId, command: params.command, params: params.profile.buildParams(params.ctx), timeoutMs, idempotencyKey: randomUUID(), }; try { const raw = await params.client.request("node.invoke", invokeParams); const payload = asRecord(raw).payload; return { command: params.command, ok: true, payload, durationMs: Math.max(1, Date.now() - startedAt), }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { command: params.command, ok: false, errorCode: parseErrorCode(message), errorMessage: message, durationMs: Math.max(1, Date.now() - startedAt), }; } } function evaluateCommandResult(params: { result: CommandResult; profile: CommandProfile; ctx: CommandContext; }): string | null { const { result, profile, ctx } = params; if (result.ok) { if (profile.outcome === "error") { return `expected error, got success`; } try { profile.onSuccess?.(result.payload, ctx); return null; } catch (err) { return err instanceof Error ? err.message : String(err); } } const code = result.errorCode ?? "UNKNOWN"; if (profile.outcome === "success") { return `expected success, got ${code}: ${result.errorMessage ?? "unknown error"}`; } const allowed = new Set(profile.allowedErrorCodes ?? []); if (allowed.has(code)) { return null; } return `unexpected error ${code}: ${result.errorMessage ?? "unknown error"}`; } describeLive("android node capability integration (preconditioned)", () => { let client: GatewayClient | null = null; let nodeId = ""; let commandsToRun: string[] = []; const ctx: CommandContext = { notifications: [] }; const results = new Map(); beforeAll(async () => { const { url, token, password } = resolveGatewayConnection(); client = await connectGatewayClient({ url, token, password }); const listRaw = await client.request("node.list", {}); const nodes = parseNodeList(listRaw); expect(nodes.length, "node.list returned no nodes").toBeGreaterThan(0); const target = selectTargetNode(nodes); nodeId = target.nodeId; if (!target.connected || !target.paired) { const pairingRaw = await client.request("node.pair.list", {}); const pairing = parsePairingList(pairingRaw); const pendingForNode = pairing.pending.filter((entry) => entry.nodeId === nodeId); const pendingHint = pendingForNode.length > 0 ? `pending request(s): ${pendingForNode.map((entry) => entry.requestId).join(", ")}` : "no pending request for selected node"; throw new Error( [ `selected node is not ready (nodeId=${nodeId}, connected=${String(target.connected)}, paired=${String(target.paired)})`, pendingHint, "precondition: open app, keep foreground, ensure pairing approved (`openclaw nodes pending` / `openclaw nodes approve `)", ].join("\n"), ); } const describeRaw = await client.request("node.describe", { nodeId }); const describeObj = asRecord(describeRaw); const commands = readStringArray(describeObj.commands); expect(commands.length, "node.describe advertised no commands").toBeGreaterThan(0); commandsToRun = commands.filter((command) => !SKIPPED_INTERACTIVE_COMMANDS.has(command)); expect( commandsToRun.length, "node.describe advertised only interactive commands (nothing runnable in CI/dev integration mode)", ).toBeGreaterThan(0); const missingProfiles = commandsToRun.filter((command) => !COMMAND_PROFILES[command]); if (missingProfiles.length > 0) { throw new Error( `unmapped advertised commands: ${missingProfiles.join(", ")} (update COMMAND_PROFILES before running this suite)`, ); } }, 60_000); afterAll(() => { client?.stop(); client = null; }); const profiledCommands = Object.keys(COMMAND_PROFILES).toSorted(); for (const command of profiledCommands) { const profile = COMMAND_PROFILES[command]; const timeout = Math.max(20_000, profile.timeoutMs ?? 20_000) + 15_000; it(`command: ${command}`, { timeout }, async () => { if (!client) { throw new Error("gateway client not connected"); } if (!commandsToRun.includes(command)) { return; } const result = await invokeNodeCommand({ client, nodeId, command, profile, ctx }); results.set(command, result); const issue = evaluateCommandResult({ result, profile, ctx }); if (!issue) { return; } const status = result.ok ? "ok" : `err:${result.errorCode ?? "UNKNOWN"}`; throw new Error( [ `${command}: ${issue}`, "summary:", `${result.command} -> ${status} (${result.durationMs}ms)`, ].join("\n"), ); }); } it("covers every advertised non-interactive command", () => { const missingRuns = commandsToRun.filter((command) => !results.has(command)); if (missingRuns.length === 0) { return; } const summary = [...results.values()] .map((entry) => { const status = entry.ok ? "ok" : `err:${entry.errorCode ?? "UNKNOWN"}`; return `${entry.command} -> ${status} (${entry.durationMs}ms)`; }) .join("\n"); throw new Error( [ `advertised commands missing execution (${missingRuns.length}/${commandsToRun.length})`, ...missingRuns, "summary:", summary, ].join("\n"), ); }); });