diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts new file mode 100644 index 00000000000..6094f255748 --- /dev/null +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -0,0 +1,532 @@ +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"), + ); + }); +});