From 26c460c9677f302de6b37a8171bdaaefb5daa578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 12:43:28 +0200 Subject: [PATCH 001/149] feat(tool-server): add Android emulator support via unified simulator-server dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android is driven by the existing `simulator-server` binary through its `android --id ` subcommand, which exposes the same HTTP/WebSocket/ stdin protocol as iOS. The blueprint now selects the subcommand based on the shape of the udid, so every gesture tool (gesture-tap/swipe/pinch/rotate/ custom, button, keyboard, rotate, screenshot, run-sequence) works on both platforms without callers branching. Things that can't route through simulator-server use platform-specific paths: - describe — uiautomator dump on Android, AXRuntime + native-devtools on iOS - launch-app, restart-app — `am start`/`monkey` on Android, simctl + native devtools on iOS - open-url — `am start VIEW` with shell-escaped URL on Android, simctl openurl on iOS - reinstall-app — `adb install -r` on Android (with optional -g/-d), simctl uninstall+install on iOS Adds 4 android-only tools (android-list-emulators, android-boot-emulator, android-stop-app, android-logcat) and workspace introspection for `android_application_id` and `android_has_gradle`. iOS behavior is preserved: platform dispatch gates every Android branch, and the simulator-server blueprint only calls `ensureAutomationEnabled` for iOS udids. Tests pin each preserved path (launch/restart/reinstall/open-url on iOS) against mock execFile so a future regression surfaces in CI. Covered by 40+ new repro tests including a blueprint-level test that asserts subcommand dispatch, stdio pipe behavior (the server treats stdin EOF as shutdown), AX-automation warmup, and press-key protocol invariants. --- packages/tool-server/package.json | 2 +- .../src/blueprints/simulator-server.ts | 24 +- .../tools/android/android-boot-emulator.ts | 225 ++++++++++++++++++ .../tools/android/android-list-emulators.ts | 30 +++ .../src/tools/android/android-logcat.ts | 73 ++++++ .../src/tools/android/android-stop-app.ts | 30 +++ .../src/tools/interactions/button.ts | 14 +- .../src/tools/interactions/describe.ts | 61 +++-- .../src/tools/interactions/gesture-swipe.ts | 10 +- .../src/tools/interactions/gesture-tap.ts | 13 +- .../src/tools/interactions/keyboard.ts | 15 +- .../src/tools/interactions/run-sequence.ts | 62 ++--- .../src/tools/interactions/screenshot.ts | 12 +- .../src/tools/simulator/launch-app.ts | 77 ++++-- .../src/tools/simulator/open-url.ts | 41 +++- .../src/tools/simulator/reinstall-app.ts | 46 +++- .../src/tools/simulator/restart-app.ts | 37 ++- .../tool-server/src/tools/simulator/rotate.ts | 8 +- .../tools/workspace/gather-workspace-data.ts | 4 +- packages/tool-server/src/utils/adb.ts | 173 ++++++++++++++ .../tool-server/src/utils/android-screen.ts | 39 +++ .../tool-server/src/utils/platform-detect.ts | 23 ++ .../tool-server/src/utils/setup-registry.ts | 12 + .../src/utils/uiautomator-parser.ts | 157 ++++++++++++ .../tool-server/src/utils/workspace-reader.ts | 37 ++- packages/tool-server/test/android-adb.test.ts | 39 +++ .../test/android-describe-screen.test.ts | 116 +++++++++ .../tool-server/test/boot-simulator.test.ts | 36 ++- .../test/describe-android-dispatch.test.ts | 158 ++++++++++++ .../tool-server/test/describe-tool.test.ts | 37 ++- .../test/launch-app-dispatch.test.ts | 163 +++++++++++++ .../test/native-devtools-status.test.ts | 4 +- .../test/open-url-dispatch.test.ts | 125 ++++++++++ .../tool-server/test/platform-detect.test.ts | 41 ++++ .../test/reinstall-app-dispatch.test.ts | 198 +++++++++++++++ .../test/restart-app-dispatch.test.ts | 122 ++++++++++ .../test/run-sequence-dispatch.test.ts | 158 ++++++++++++ .../test/simulator-server-blueprint.test.ts | 164 +++++++++++++ .../tool-server/test/workspace-reader.test.ts | 42 ++++ 39 files changed, 2467 insertions(+), 161 deletions(-) create mode 100644 packages/tool-server/src/tools/android/android-boot-emulator.ts create mode 100644 packages/tool-server/src/tools/android/android-list-emulators.ts create mode 100644 packages/tool-server/src/tools/android/android-logcat.ts create mode 100644 packages/tool-server/src/tools/android/android-stop-app.ts create mode 100644 packages/tool-server/src/utils/adb.ts create mode 100644 packages/tool-server/src/utils/android-screen.ts create mode 100644 packages/tool-server/src/utils/platform-detect.ts create mode 100644 packages/tool-server/src/utils/uiautomator-parser.ts create mode 100644 packages/tool-server/test/android-adb.test.ts create mode 100644 packages/tool-server/test/android-describe-screen.test.ts create mode 100644 packages/tool-server/test/describe-android-dispatch.test.ts create mode 100644 packages/tool-server/test/launch-app-dispatch.test.ts create mode 100644 packages/tool-server/test/open-url-dispatch.test.ts create mode 100644 packages/tool-server/test/platform-detect.test.ts create mode 100644 packages/tool-server/test/reinstall-app-dispatch.test.ts create mode 100644 packages/tool-server/test/restart-app-dispatch.test.ts create mode 100644 packages/tool-server/test/run-sequence-dispatch.test.ts create mode 100644 packages/tool-server/test/simulator-server-blueprint.test.ts diff --git a/packages/tool-server/package.json b/packages/tool-server/package.json index 4e068b88..7485838b 100644 --- a/packages/tool-server/package.json +++ b/packages/tool-server/package.json @@ -2,7 +2,7 @@ "private": true, "name": "@argent/tool-server", "version": "0.5.2", - "description": "Framework-agnostic tool registry for iOS simulator control", + "description": "Framework-agnostic tool registry for iOS simulator and Android emulator control", "main": "dist/index.js", "scripts": { "build": "rm -rf dist tsconfig.tsbuildinfo && tsc && cp src/utils/ios-profiler/Argent.tracetemplate dist/utils/ios-profiler/", diff --git a/packages/tool-server/src/blueprints/simulator-server.ts b/packages/tool-server/src/blueprints/simulator-server.ts index 255c7a9c..50ad87ca 100644 --- a/packages/tool-server/src/blueprints/simulator-server.ts +++ b/packages/tool-server/src/blueprints/simulator-server.ts @@ -8,6 +8,7 @@ import { } from "@argent/registry"; import { simulatorServerBinaryPath, simulatorServerBinaryDir } from "@argent/native-devtools-ios"; import { ensureAutomationEnabled } from "./ax-service"; +import { detectPlatform } from "../utils/platform-detect"; export const SIMULATOR_SERVER_NAMESPACE = "SimulatorServer"; @@ -26,14 +27,25 @@ export interface SimulatorServerApi { pressKey(direction: "Down" | "Up", keyCode: number): void; } +/** + * Spawn `simulator-server --id `. + * + * Android mode uses the gRPC EmulatorController to drive the AVD, iOS mode uses + * Apple's private simctl APIs. From the tool-server's perspective both expose + * the same HTTP + WebSocket + stdin protocol, so every caller is platform-neutral. + * + * stdin MUST stay open — the server treats EOF on stdin as a shutdown signal. + * `stdio: ["pipe", "pipe", "pipe"]` below provides that. + */ function spawnSimulatorServerProcess(udid: string): Promise<{ proc: ChildProcess; apiUrl: string; streamUrl: string; }> { const { BINARY_PATH, BINARY_DIR } = getPaths(); + const subcommand = detectPlatform(udid) === "android" ? "android" : "ios"; return new Promise((resolve, reject) => { - const args = ["ios", "--id", udid]; + const args = [subcommand, "--id", udid]; const proc = spawn(BINARY_PATH, args, { cwd: BINARY_DIR, @@ -95,11 +107,11 @@ export const simulatorServerBlueprint: ServiceBlueprint {}); + // iOS accessibility automation flag — no-op equivalent on Android so skip + // the xcrun call entirely there. + if (detectPlatform(udid) === "ios") { + await ensureAutomationEnabled(udid).catch(() => {}); + } const { proc, apiUrl, streamUrl } = await spawnSimulatorServerProcess(udid); const events = new TypedEventEmitter(); diff --git a/packages/tool-server/src/tools/android/android-boot-emulator.ts b/packages/tool-server/src/tools/android/android-boot-emulator.ts new file mode 100644 index 00000000..4b07bb2d --- /dev/null +++ b/packages/tool-server/src/tools/android/android-boot-emulator.ts @@ -0,0 +1,225 @@ +import { spawn } from "node:child_process"; +import { z } from "zod"; +import type { ToolDefinition } from "@argent/registry"; +import { + adbShell, + emulatorBinaryName, + listAndroidDevices, + listAvds, + runAdb, + waitForBootCompleted, +} from "../../utils/adb"; + +const zodSchema = z.object({ + avdName: z + .string() + .describe("AVD name to boot (from `android-list-emulators`). Example: `Pixel_7_API_34`."), + coldBoot: z + .boolean() + .optional() + .describe( + "Skip the AVD snapshot and cold-boot. Defaults to true — cold boot is slower but avoids " + + "the common failure where a corrupt snapshot leaves the emulator stuck at `offline` for several minutes." + ), + noWindow: z + .boolean() + .optional() + .describe( + "Launch the emulator headless (no UI window). Useful for CI. Defaults to false — " + + "the UI surfaces boot progress, which helps when diagnosing slow cold boots." + ), + bootTimeoutMs: z + .number() + .int() + .min(30_000) + .max(900_000) + .optional() + .describe( + "Overall budget for the full boot sequence (adb-appearance + boot_completed). Defaults to 480000 (8 min). Clamped to [30s, 15min]." + ), +}); + +// Each stage has its own sub-budget so a hang in one stage cannot consume the +// entire overall budget and a bootTimeoutMs bump doesn't quietly mask a regression. +const STAGE_BUDGET = { + qemuVisible: 30_000, // time from spawn → qemu-system-* process alive + adbRegister: 60_000, // adb devices sees the serial for this AVD + deviceReady: 180_000, // adb -s wait-for-device returns (state === "device") + bootCompleted: 300_000, // sys.boot_completed = 1 +} as const; + +async function killEmulatorQuietly(serial: string | null): Promise { + if (serial) { + await runAdb(["-s", serial, "emu", "kill"], { timeoutMs: 5_000 }).catch(() => {}); + } +} + +async function findSerialByAvdName(avdName: string, deadline: number): Promise { + while (Date.now() < deadline) { + const devices = await listAndroidDevices().catch(() => []); + const match = devices.find((d) => d.isEmulator && d.avdName === avdName); + if (match) return match.serial; + await new Promise((r) => setTimeout(r, 1_500)); + } + return null; +} + +async function listNewEmulatorSerials(before: Set): Promise { + const { stdout } = await runAdb(["devices"]).catch(() => ({ stdout: "", stderr: "" })); + const lines = stdout.split("\n"); + const now: string[] = []; + for (const line of lines) { + const m = line.match(/^(emulator-\d+)\s+/); + if (m) now.push(m[1]!); + } + return now.filter((s) => !before.has(s)); +} + +export const androidBootEmulatorTool: ToolDefinition< + z.infer, + { booted: boolean; serial: string; avdName: string; coldBoot: boolean } +> = { + id: "android-boot-emulator", + description: + "Start an Android emulator by AVD name and wait until it finishes booting. " + + "Cold-boots by default (skips the snapshot) because corrupt snapshots are the #1 cause of silent boot hangs. " + + "Expect 2–5 minutes on Apple Silicon; 5–10 minutes on older machines or cold disks. " + + "Returns { booted, serial, avdName, coldBoot }. On any stage failure the tool kills the emulator process it started and returns a clear error, so the next call begins from a clean state.", + zodSchema, + services: () => ({}), + async execute(_services, params) { + const overallBudget = params.bootTimeoutMs ?? 480_000; + const overallDeadline = Date.now() + overallBudget; + // Default to TRUE — reliability over speed per user direction. Callers who + // need a warm boot for speed can opt in explicitly. + const coldBoot = params.coldBoot ?? true; + + // ── Stage 0: validate AVD exists ────────────────────────────────── + const avds = await listAvds(); + if (avds.length === 0) { + throw new Error( + "`emulator -list-avds` returned no AVDs. Either the Android Emulator package is not on PATH, " + + "or no AVDs are defined. Create one via Android Studio or `avdmanager create avd`." + ); + } + if (!avds.some((a) => a.name === params.avdName)) { + throw new Error( + `AVD "${params.avdName}" not found. Available: ${avds.map((a) => a.name).join(", ")}.` + ); + } + + // Snapshot the serials already known so we can identify the new one, as a + // fallback if the AVD-name lookup (via getprop) is slow to return. + const serialsBefore = new Set( + (await listAndroidDevices().catch(() => [])).map((d) => d.serial) + ); + + // ── Stage 1: spawn emulator ─────────────────────────────────────── + const emulatorArgs = ["-avd", params.avdName]; + if (coldBoot) emulatorArgs.push("-no-snapshot-load"); + if (params.noWindow) emulatorArgs.push("-no-window"); + // `-delay-adb` and `-read-only` would complicate the reliability story. + // Keep the arg set minimal so failure modes are easy to reason about. + + const child = spawn(emulatorBinaryName(), emulatorArgs, { + detached: true, + stdio: "ignore", + }); + child.unref(); + + let earlyExitError: Error | null = null; + child.on("exit", (code) => { + if (code !== 0 && code !== null) { + earlyExitError = new Error( + `emulator binary exited with code ${code} before the device booted. ` + + `Common causes: AVD corrupted, Hypervisor unavailable, or disk full. ` + + `Try \`emulator -avd ${params.avdName} -verbose\` from a terminal to see the exact error.` + ); + } + }); + + // Ensure adb daemon is up so the new device socket registers promptly. + await runAdb(["start-server"], { timeoutMs: 10_000 }).catch(() => {}); + + // ── Stage 2: wait for adb to see the new emulator ───────────────── + let serial: string | null = null; + const adbDeadline = Math.min(overallDeadline, Date.now() + STAGE_BUDGET.adbRegister); + while (Date.now() < adbDeadline) { + if (earlyExitError) { + throw earlyExitError; + } + const newSerials = await listNewEmulatorSerials(serialsBefore); + if (newSerials.length >= 1) { + // If exactly one new emulator, adopt its serial. If multiple, prefer the + // AVD-name match. + if (newSerials.length === 1) { + serial = newSerials[0]!; + break; + } + const byAvd = await findSerialByAvdName(params.avdName, Date.now() + 3_000); + if (byAvd) { + serial = byAvd; + break; + } + } + await new Promise((r) => setTimeout(r, 1_000)); + } + if (!serial) { + await killEmulatorQuietly(null); + throw new Error( + `Emulator "${params.avdName}" did not register with adb within ${STAGE_BUDGET.adbRegister / 1000}s. ` + + `Check that the Android SDK is on PATH and that no other emulator is already using the assigned port.` + ); + } + + // ── Stage 3: wait-for-device (tcp socket up) ────────────────────── + try { + await runAdb(["-s", serial, "wait-for-device"], { + timeoutMs: Math.min( + STAGE_BUDGET.deviceReady, + Math.max(1_000, overallDeadline - Date.now()) + ), + }); + } catch (err) { + await killEmulatorQuietly(serial); + throw new Error( + `adb wait-for-device failed for ${serial}: ${ + err instanceof Error ? err.message : String(err) + }. Emulator has been terminated; retry in a moment.` + ); + } + + // ── Stage 4: sys.boot_completed = 1 ─────────────────────────────── + const bootBudget = Math.max( + 10_000, + Math.min(STAGE_BUDGET.bootCompleted, overallDeadline - Date.now()) + ); + try { + await waitForBootCompleted(serial, bootBudget); + } catch (err) { + await killEmulatorQuietly(serial); + throw new Error( + `${err instanceof Error ? err.message : String(err)} ` + + `Emulator has been terminated so the next boot starts clean. ` + + `If this keeps happening, the AVD's snapshot may be corrupt — the tool already cold-boots by default, ` + + `but you can also manually wipe user data with \`emulator -avd ${params.avdName} -wipe-data\` from a shell.` + ); + } + + // ── Stage 5: one final sanity probe ─────────────────────────────── + // `pm` responds only after PackageManagerService is up. This prevents the + // tool from returning `booted: true` while subsequent `am start` / `pm list` + // calls would still 500 for ~10-30s. + try { + await adbShell(serial, "pm path android", { timeoutMs: 10_000 }); + } catch (err) { + await killEmulatorQuietly(serial); + throw new Error( + `PackageManager did not respond on ${serial} after boot_completed. ` + + `Emulator has been terminated. Retry the call.` + ); + } + + return { booted: true, serial, avdName: params.avdName, coldBoot }; + }, +}; diff --git a/packages/tool-server/src/tools/android/android-list-emulators.ts b/packages/tool-server/src/tools/android/android-list-emulators.ts new file mode 100644 index 00000000..33d88d98 --- /dev/null +++ b/packages/tool-server/src/tools/android/android-list-emulators.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import type { ToolDefinition } from "@argent/registry"; +import { listAndroidDevices, listAvds } from "../../utils/adb"; + +const zodSchema = z.object({}); + +export const androidListEmulatorsTool: ToolDefinition = { + id: "android-list-emulators", + description: + "List Android devices and emulators known to adb, plus available AVDs from `emulator -list-avds`. " + + "Use when you need a `serial` to pass to other android-* tools, or to check which emulators are already running. " + + "Returns { devices: [{ serial, state, isEmulator, model, avdName, sdkLevel }], avds: [{ name }] }. " + + "`state` is `device` (ready), `offline`, or `unauthorized`. " + + "Requires the Android SDK Platform Tools (adb) on PATH; AVD listing requires the Emulator package.", + zodSchema, + services: () => ({}), + async execute(_services, _params) { + const [devices, avds] = await Promise.all([listAndroidDevices(), listAvds()]); + // Sort ready devices first, then emulators before physical, for a predictable "pick the first" default. + devices.sort((a, b) => { + const aReady = a.state === "device" ? 0 : 1; + const bReady = b.state === "device" ? 0 : 1; + if (aReady !== bReady) return aReady - bReady; + const aEmu = a.isEmulator ? 0 : 1; + const bEmu = b.isEmulator ? 0 : 1; + return aEmu - bEmu; + }); + return { devices, avds }; + }, +}; diff --git a/packages/tool-server/src/tools/android/android-logcat.ts b/packages/tool-server/src/tools/android/android-logcat.ts new file mode 100644 index 00000000..c3093074 --- /dev/null +++ b/packages/tool-server/src/tools/android/android-logcat.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import type { ToolDefinition } from "@argent/registry"; +import { adbShell, runAdb } from "../../utils/adb"; +import { detectPlatform } from "../../utils/platform-detect"; + +const zodSchema = z.object({ + udid: z.string().describe("Android adb serial (e.g. `emulator-5554`)."), + bundleId: z + .string() + .optional() + .describe( + "If provided, only include log lines emitted by this package's process. Resolved via `pidof ` first." + ), + priority: z + .enum(["V", "D", "I", "W", "E", "F"]) + .optional() + .describe("Minimum log priority. V=verbose D=debug I=info W=warn E=error F=fatal. Default: I."), + lines: z + .number() + .int() + .min(1) + .max(10_000) + .optional() + .describe("Max number of most-recent lines to return (default 500)."), + tag: z.string().optional().describe("Filter to a single logcat tag."), +}); + +export const androidLogcatTool: ToolDefinition< + z.infer, + { lines: string[]; count: number } +> = { + id: "android-logcat", + description: + "Read recent logcat output from the device. Uses `adb logcat -d` (dump) so it returns immediately without streaming. " + + "Filters by package (via PID), priority, and optional tag. Returns { lines, count }. " + + "Use for crash traces, React Native red-box details, or general runtime diagnostics.", + zodSchema, + services: () => ({}), + async execute(_services, params) { + if (detectPlatform(params.udid) !== "android") { + throw new Error("android-logcat is Android-only."); + } + let pid: string | null = null; + if (params.bundleId) { + // `pidof ` returns one or more whitespace-separated PIDs (the app may + // have child processes). Pass the first; if empty, the app isn't running. + const raw = ( + await adbShell(params.udid, `pidof ${params.bundleId}`, { + timeoutMs: 5_000, + }).catch(() => "") + ).trim(); + pid = raw.split(/\s+/)[0] ?? null; + if (!pid) { + return { lines: [], count: 0 }; + } + } + + const args = ["-s", params.udid, "logcat", "-d", "-v", "threadtime"]; + if (pid) args.push("--pid", pid); + if (params.tag) { + // Filter to one tag at the requested priority, silence the rest. + args.push(`${params.tag}:${params.priority ?? "V"}`, "*:S"); + } else if (params.priority) { + args.push(`*:${params.priority}`); + } + + const { stdout } = await runAdb(args, { timeoutMs: 20_000 }); + const all = stdout.split("\n").filter((l) => l.length > 0); + const maxLines = params.lines ?? 500; + const tail = all.slice(-maxLines); + return { lines: tail, count: tail.length }; + }, +}; diff --git a/packages/tool-server/src/tools/android/android-stop-app.ts b/packages/tool-server/src/tools/android/android-stop-app.ts new file mode 100644 index 00000000..f1de4d36 --- /dev/null +++ b/packages/tool-server/src/tools/android/android-stop-app.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import type { ToolDefinition } from "@argent/registry"; +import { adbShell } from "../../utils/adb"; +import { detectPlatform } from "../../utils/platform-detect"; + +const zodSchema = z.object({ + udid: z.string().describe("Android adb serial (e.g. `emulator-5554`)."), + bundleId: z.string().describe("Android package name to force-stop."), +}); + +export const androidStopAppTool: ToolDefinition< + z.infer, + { stopped: boolean; bundleId: string } +> = { + id: "android-stop-app", + description: + "Force-stop an Android app without relaunching it. Android-only — no iOS equivalent (use `restart-app` for iOS). " + + "Returns { stopped, bundleId }. Does not error if the app was not running.", + zodSchema, + services: () => ({}), + async execute(_services, params) { + if (detectPlatform(params.udid) !== "android") { + throw new Error( + "android-stop-app is Android-only. For iOS use `restart-app` (terminate + relaunch)." + ); + } + await adbShell(params.udid, `am force-stop ${params.bundleId}`, { timeoutMs: 15_000 }); + return { stopped: true, bundleId: params.bundleId }; + }, +}; diff --git a/packages/tool-server/src/tools/interactions/button.ts b/packages/tool-server/src/tools/interactions/button.ts index f5569b11..2806c335 100644 --- a/packages/tool-server/src/tools/interactions/button.ts +++ b/packages/tool-server/src/tools/interactions/button.ts @@ -6,7 +6,11 @@ import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + ), button: z .enum(["home", "back", "power", "volumeUp", "volumeDown", "appSwitch", "actionButton"]) .describe("Hardware button to press"), @@ -14,11 +18,9 @@ const zodSchema = z.object({ export const buttonTool: ToolDefinition, { pressed: string }> = { id: "button", - description: `Press a simulator hardware button. Sends Down then Up events automatically. -Supported buttons: home, back, power, volumeUp, volumeDown, appSwitch, actionButton. -Use when you need to trigger a hardware button events. -Returns { pressed: buttonName }. -Fails if the simulator server is not running for the given UDID.`, + description: `Press a hardware button on iOS or Android. Sends Down then Up events automatically. +Supported: home, back, power, volumeUp, volumeDown, appSwitch, actionButton. The simulator-server binary maps these to each platform's native keycode internally. +Returns { pressed: buttonName }. Fails if the simulator server cannot start.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/describe.ts b/packages/tool-server/src/tools/interactions/describe.ts index d2a4ad00..90a4bb1f 100644 --- a/packages/tool-server/src/tools/interactions/describe.ts +++ b/packages/tool-server/src/tools/interactions/describe.ts @@ -9,44 +9,67 @@ import { adaptAXDescribeToDescribeResult } from "./describe-ax-adapter"; import { adaptNativeDescribeToDescribeResult } from "./describe-native-adapter"; import { parseNativeDescribeScreenResult } from "../native-devtools/native-describe-contract"; import { resolveNativeTargetApp } from "../../utils/native-target-app"; +import { detectPlatform } from "../../utils/platform-detect"; +import { adbExecOutBinary } from "../../utils/adb"; +import { getAndroidScreenSize } from "../../utils/android-screen"; +import { parseUiAutomatorDump } from "../../utils/uiautomator-parser"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." + ), bundleId: z .string() .optional() .describe( - "Optional app bundle ID. Used as a target hint when the AX-service returns no elements " + - "and the describe tool falls back to native-devtools inspection. " + - "If omitted, the fallback auto-detects the frontmost connected app." + "iOS-only: target hint when AX-service returns nothing and the tool falls back to native-devtools inspection. " + + "If omitted, falls back to the frontmost connected app. Ignored on Android." ), }); +async function describeAndroid(udid: string): Promise { + const [size, rawBuf] = await Promise.all([ + getAndroidScreenSize(udid), + adbExecOutBinary( + udid, + "uiautomator dump /sdcard/window_dump.xml >/dev/null && cat /sdcard/window_dump.xml", + { timeoutMs: 20_000 } + ), + ]); + const raw = rawBuf.toString("utf-8"); + const trimmed = raw.trim(); + if (/^ERROR:/i.test(trimmed) || (!trimmed.includes(", DescribeResult> { return { id: "describe", - description: `Get the iOS accessibility element tree for the current simulator screen. -Uses the AXRuntime accessibility service to inspect whatever is currently visible — including -system dialogs, permission prompts, and any foreground app content. + description: `Get the UI hierarchy for the current screen on iOS or Android. -When a system dialog is visible, describe returns the dialog's interactive elements (buttons, text) -with tap coordinates. When no dialog is present, it returns the foreground app's accessible elements. +iOS: accessibility element tree from AXRuntime. Returns dialog elements when a system modal is visible, otherwise the foreground app's accessible tree. Falls back to native-devtools inspection if AX is empty. +Android: uiautomator dump parsed into the same DescribeNode shape. Uses \`resource-id\` as identifier, \`content-desc\`/\`text\` as label. -Returns a JSON tree of UI elements with roles, labels, values, and frame coordinates in normalized -[0,1] space (fractions of the screen, not pixels) — the same coordinate space as tap/swipe/gesture -and simulator-server touch input. +Both return frame coordinates normalized to [0,1] — same coord space as gesture-tap. Use frame.x + frame.width/2 as tap X, frame.y + frame.height/2 as tap Y. -Use frame.x + frame.width/2 as the tap X coordinate, frame.y + frame.height/2 as tap Y. - -For app-scoped inspection with full UIKit properties (accessibilityIdentifier, viewClassName), -use native-describe-screen with an explicit bundleId instead. -For React Native apps, debugger-component-tree returns React component names with tap coordinates. -Only supported on iOS simulators.`, +For React Native apps on either platform, \`debugger-component-tree\` returns richer component data (requires Metro connection; on Android also requires \`adb reverse tcp:8081 tcp:8081\`).`, zodSchema, services: () => ({}), async execute(_services, params, _options) { + if (detectPlatform(params.udid) === "android") { + return describeAndroid(params.udid); + } const axApi = await registry.resolveService( `${AX_SERVICE_NAMESPACE}:${params.udid}` ); @@ -57,7 +80,6 @@ Only supported on iOS simulators.`, return { tree, source: "ax-service" }; } - // AX returned zero elements — attempt native-devtools fallback try { const nativeApi = await registry.resolveService( `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}` @@ -82,7 +104,6 @@ Only supported on iOS simulators.`, const nativeTree = adaptNativeDescribeToDescribeResult(parsed); return { tree: nativeTree, source: "native-devtools" }; } catch { - // Native devtools unavailable or no connected app — return the empty AX result return { tree, source: "ax-service" }; } }, diff --git a/packages/tool-server/src/tools/interactions/gesture-swipe.ts b/packages/tool-server/src/tools/interactions/gesture-swipe.ts index e73f93d7..79de97bc 100644 --- a/packages/tool-server/src/tools/interactions/gesture-swipe.ts +++ b/packages/tool-server/src/tools/interactions/gesture-swipe.ts @@ -6,7 +6,11 @@ import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + ), fromX: z.number().describe("Start x: normalized 0.0–1.0 (not pixels; same as tap)"), fromY: z.number().describe("Start y: normalized 0.0–1.0 (not pixels; same as tap)"), toX: z.number().describe("End x: normalized 0.0–1.0 (not pixels; same as tap)"), @@ -22,11 +26,11 @@ export const gestureSwipeTool: ToolDefinition< { swiped: boolean; timestampMs: number } > = { id: "gesture-swipe", - description: `Execute a smooth swipe gesture between two points. All from/to positions are normalized 0.0–1.0 (fractions of screen width/height, not pixels), same as gesture-tap and simulator-server touch. + description: `Execute a smooth swipe gesture between two points on iOS or Android. All from/to positions are normalized 0.0–1.0 (fractions of screen width/height, not pixels), same as gesture-tap and simulator-server touch. Generates interpolated Move events for a natural feel (~60fps). Swipe up (fromY > toY) to scroll content down. Swipe down (fromY < toY) to scroll content up. -Use when you need to scroll a list, dismiss a modal, or navigate between pages. Returns { swiped: true, timestampMs }. Fails if the simulator server is not running for the given UDID.`, +Use when you need to scroll a list, dismiss a modal, or navigate between pages. Returns { swiped: true, timestampMs }. Fails if the simulator server cannot start.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/gesture-tap.ts b/packages/tool-server/src/tools/interactions/gesture-tap.ts index 8d6fbcc6..7bb98814 100644 --- a/packages/tool-server/src/tools/interactions/gesture-tap.ts +++ b/packages/tool-server/src/tools/interactions/gesture-tap.ts @@ -6,7 +6,11 @@ import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + ), x: z.number().describe("Normalized horizontal position 0.0–1.0 (left=0, right=1), not pixels"), y: z.number().describe("Normalized vertical position 0.0–1.0 (top=0, bottom=1), not pixels"), }); @@ -16,11 +20,10 @@ export const gestureTapTool: ToolDefinition< { tapped: boolean; timestampMs: number } > = { id: "gesture-tap", - description: `Press the simulator screen at normalized coordinates: x and y are fractions of screen width and height in 0.0–1.0 (not pixels), matching simulator-server touch input. + description: `Press the screen at normalized coordinates on iOS or Android. x and y are fractions of screen width and height in 0.0–1.0 (not pixels), matching simulator-server touch input. Sends a Down event followed by an Up event at the same point. -Use when you need to tap a button, link, or any tappable element on the simulator screen. -Returns { tapped: true, timestampMs }. Fails if the simulator server is not running for the given UDID. -Before tapping, determine the correct coordinates by using discovery tools: describe, native-describe-screen, debugger-component-tree. More information in \`simulator-interact\` skill`, +Use when you need to tap a button, link, or any tappable element. Returns { tapped: true, timestampMs }. Fails if the simulator server cannot start for the given udid (e.g. device not booted). +Before tapping, determine coordinates with a discovery tool: \`describe\`, \`debugger-component-tree\`, or \`native-describe-screen\` (iOS only). More in the \`argent-simulator-interact\` skill.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/keyboard.ts b/packages/tool-server/src/tools/interactions/keyboard.ts index 69d986e5..99b4035e 100644 --- a/packages/tool-server/src/tools/interactions/keyboard.ts +++ b/packages/tool-server/src/tools/interactions/keyboard.ts @@ -140,12 +140,16 @@ const NAMED_KEYS: Record = { }; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + ), text: z .string() .optional() .describe( - "Text to type character by character. Handles uppercase and common punctuation. Use when paste is unreliable." + "Text to type character by character via USB HID keycodes through simulator-server. Handles uppercase and common punctuation. Use when paste is unreliable." ), key: z .string() @@ -161,12 +165,13 @@ export const keyboardTool: ToolDefinition< { typed: string; keys: number } > = { id: "keyboard", - description: `Type text or press special keys on the simulator using keyboard events. + description: `Type text or press special keys on iOS or Android using keyboard events. +Uses USB HID keycodes routed through simulator-server; the binary maps them to each platform's native key events internally. Use when you need to enter text or trigger a named key such as enter, escape, or arrow keys. -Returns { typed: string, keys: number }. Fails if an unsupported key name is provided or the simulator server is not running. +Returns { typed: string, keys: number }. Fails on unsupported key names or if the simulator server cannot start. - text: types a string character by character (supports uppercase, digits, common punctuation) - key: presses a single named key (enter, escape, backspace, tab, arrow-up/down/left/right, f1–f12) -Provide text, key, or both. Use instead of paste when paste is unreliable or unsupported by the focused field.`, +Provide text, key, or both.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/run-sequence.ts b/packages/tool-server/src/tools/interactions/run-sequence.ts index 952f2605..bc416a52 100644 --- a/packages/tool-server/src/tools/interactions/run-sequence.ts +++ b/packages/tool-server/src/tools/interactions/run-sequence.ts @@ -3,6 +3,8 @@ import type { Registry, ToolDefinition } from "@argent/registry"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +// Unified tool names — simulator-server dispatches iOS vs Android internally, +// so every tool below works on both platforms with a consistent shape. const ALLOWED_TOOLS = new Set([ "gesture-tap", "gesture-swipe", @@ -15,7 +17,11 @@ const ALLOWED_TOOLS = new Set([ ]); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID (shared across all steps)"), + udid: z + .string() + .describe( + "Device id shared across all steps. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + ), steps: z .array( z.object({ @@ -50,45 +56,27 @@ export function createRunSequenceTool( ): ToolDefinition, RunSequenceResult> { return { id: "run-sequence", - description: `Execute multiple simulator interaction steps in a single call. + description: `Execute multiple interaction steps in a single call, on iOS or Android. Use when you need sequential actions and do NOT need to observe the screen between them (e.g. scrolling multiple times, typing then pressing enter, rotating back and forth). -Returns { completed, total, steps } with per-step results. Fails if an unrecognised tool name is used in a step (error returned at that step, execution stops). -No screenshot is captured automatically — call screenshot separately after the sequence if needed. - -ONLY use this when every step is known in advance. If any step depends on the -result of a previous one (e.g. tapping a menu item that only appears after -a prior tap), use individual tool calls instead. - -Allowed tools and their args (udid is auto-injected, do NOT include it in args): - - gesture-tap: { x: number, y: number } - gesture-swipe: { fromX: number, fromY: number, toX: number, toY: number, durationMs?: number } - gesture-custom: { events: [{ type: "Down"|"Move"|"Up", x: number, y: number, x2?: number, y2?: number, delayMs?: number }], interpolate?: number } - gesture-pinch: { centerX: number, centerY: number, startDistance: number, endDistance: number, angle?: number, durationMs?: number } - gesture-rotate: { centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, durationMs?: number } - button: { button: "home"|"back"|"power"|"volumeUp"|"volumeDown"|"appSwitch"|"actionButton" } - keyboard: { text?: string, key?: string, delayMs?: number } - rotate: { orientation: "Portrait"|"LandscapeLeft"|"LandscapeRight"|"PortraitUpsideDown" } - -Example — scroll down three times: - { "udid": "", "steps": [ - { "tool": "gesture-swipe", "args": { "fromX": 0.5, "fromY": 0.7, "toX": 0.5, "toY": 0.3 } }, - { "tool": "gesture-swipe", "args": { "fromX": 0.5, "fromY": 0.7, "toX": 0.5, "toY": 0.3 } }, - { "tool": "gesture-swipe", "args": { "fromX": 0.5, "fromY": 0.7, "toX": 0.5, "toY": 0.3 } } - ]} - -Example — type text and submit: - { "udid": "", "steps": [ - { "tool": "keyboard", "args": { "text": "hello world" } }, - { "tool": "keyboard", "args": { "key": "enter" } } - ]} - -Stops on the first error and returns partial results.`, +Returns { completed, total, steps }. Stops on the first error and returns partial results. +No screenshot is captured automatically — call \`screenshot\` separately after the sequence if needed. + +ONLY use this when every step is known in advance. If any step depends on the result of a previous one +(e.g. tapping a menu item that only appears after a prior tap), use individual tool calls instead. + +Allowed tools and their args (udid is auto-injected — do NOT include it in args): + + gesture-tap: { x, y } + gesture-swipe: { fromX, fromY, toX, toY, durationMs? } + gesture-custom: { events: [...], interpolate? } + gesture-pinch: { centerX, centerY, startDistance, endDistance, angle?, durationMs? } + gesture-rotate: { centerX, centerY, radius, startAngle, endAngle, durationMs? } + button: { button: "home"|"back"|... } + keyboard: { text?, key? } + rotate: { orientation: "Portrait"|... }`, zodSchema, - services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), + services: () => ({}), async execute(_services, params) { const { udid, steps } = params; const results: StepResult[] = []; diff --git a/packages/tool-server/src/tools/interactions/screenshot.ts b/packages/tool-server/src/tools/interactions/screenshot.ts index b9da198f..bd5e4911 100644 --- a/packages/tool-server/src/tools/interactions/screenshot.ts +++ b/packages/tool-server/src/tools/interactions/screenshot.ts @@ -4,7 +4,11 @@ import type { SimulatorServerApi } from "../../blueprints/simulator-server"; import { httpScreenshot } from "../../utils/simulator-client"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + ), rotation: z .enum(["Portrait", "LandscapeLeft", "LandscapeRight", "PortraitUpsideDown"]) .optional() @@ -24,9 +28,9 @@ export const screenshotTool: ToolDefinition< { url: string; path: string } > = { id: "screenshot", - description: `Capture a screenshot of the simulator screen. Returns { url, path } and the MCP adapter renders it as a visible image. -Use when you need a baseline image before an interaction or to inspect the current screen state after a delay. -Fails if the simulator server is not running or the screenshot request times out.`, + description: `Capture a screenshot of the device screen on iOS or Android. Returns { url, path }; the MCP adapter renders it as a visible image. +Use when you need a baseline before an interaction or to inspect the current screen after a delay. +Both platforms route through simulator-server which serves the PNG over HTTP. Fails if the simulator server cannot start or the screenshot request times out.`, zodSchema, outputHint: "image", services: (params) => ({ diff --git a/packages/tool-server/src/tools/simulator/launch-app.ts b/packages/tool-server/src/tools/simulator/launch-app.ts index 943f7f7c..7110db5d 100644 --- a/packages/tool-server/src/tools/simulator/launch-app.ts +++ b/packages/tool-server/src/tools/simulator/launch-app.ts @@ -1,15 +1,32 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolDefinition } from "@argent/registry"; import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { detectPlatform } from "../../utils/platform-detect"; +import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), - bundleId: z.string().describe("App bundle identifier (e.g. com.apple.MobileSMS)"), + udid: z + .string() + .describe( + "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." + ), + bundleId: z + .string() + .describe( + "App identifier. iOS: bundle id (e.g. com.apple.MobileSMS). Android: package name (e.g. com.android.settings) — the `applicationId` from build.gradle." + ), + activity: z + .string() + .optional() + .describe( + "Android-only: optional fully-qualified Activity name (e.g. `.MainActivity` or `com.example/com.example.MainActivity`). " + + "If omitted on Android, the default launcher activity is used via `monkey`. Ignored on iOS." + ), }); export const launchAppTool: ToolDefinition< @@ -17,26 +34,46 @@ export const launchAppTool: ToolDefinition< { launched: boolean; bundleId: string } > = { id: "launch-app", - description: `Open an app on the simulator by bundle ID. -Use when starting any app — prefer this over tapping home-screen icons. Also prepares native-devtools launch injection before the app starts. Returns { launched, bundleId }. Fails if the bundle ID is not installed on the simulator. + description: `Open an app by bundle id (iOS) or package name (Android). Prefer this over tapping home-screen / launcher icons. -Common bundle IDs: -- Messages: com.apple.MobileSMS -- Safari: com.apple.mobilesafari -- Settings: com.apple.Preferences -- Maps: com.apple.Maps -- Camera: com.apple.camera -- Photos: com.apple.Photos -- Mail: com.apple.mobilemail -- Notes: com.apple.mobilenotes -- Clock: com.apple.mobiletimer -- Calendar: com.apple.mobilecal -- Contacts: com.apple.MobileAddressBook`, +iOS: uses \`xcrun simctl launch\`; prepares native-devtools launch injection before the app starts. +Android: uses \`am start -n /\` when \`activity\` is provided, otherwise sends a LAUNCHER intent via \`monkey\`. + +Returns { launched, bundleId }. Fails if the app is not installed on the device. + +Common iOS bundle ids: com.apple.MobileSMS, com.apple.mobilesafari, com.apple.Preferences, com.apple.Maps, com.apple.camera, com.apple.Photos, com.apple.mobilemail, com.apple.mobilenotes, com.apple.MobileAddressBook +Common Android packages: com.android.settings, com.android.chrome, com.google.android.apps.maps, com.google.android.gm, com.android.vending, com.google.android.dialer, com.google.android.apps.messaging`, zodSchema, - services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, - }), + services: (params): Record => + detectPlatform(params.udid) === "ios" + ? { nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}` } + : {}, async execute(services, params) { + if (detectPlatform(params.udid) === "android") { + if (params.activity) { + const component = params.activity.startsWith(".") + ? `${params.bundleId}/${params.activity}` + : params.activity.includes("/") + ? params.activity + : `${params.bundleId}/${params.activity}`; + const out = await adbShell(params.udid, `am start -W -n ${component}`, { + timeoutMs: 30_000, + }); + if (/Error|Exception/i.test(out) && !/Status: ok/i.test(out)) { + throw new Error(`am start failed: ${out.trim()}`); + } + } else { + const out = await adbShell( + params.udid, + `monkey -p ${params.bundleId} -c android.intent.category.LAUNCHER 1`, + { timeoutMs: 30_000 } + ); + if (/No activities found|Error:/i.test(out)) { + throw new Error(`monkey launch failed: ${out.trim()}`); + } + } + return { launched: true, bundleId: params.bundleId }; + } const api = services.nativeDevtools as NativeDevtoolsApi; await api.ensureEnvReady(); await execFileAsync("xcrun", ["simctl", "launch", params.udid, params.bundleId]); diff --git a/packages/tool-server/src/tools/simulator/open-url.ts b/packages/tool-server/src/tools/simulator/open-url.ts index 6da1026a..fe08d66e 100644 --- a/packages/tool-server/src/tools/simulator/open-url.ts +++ b/packages/tool-server/src/tools/simulator/open-url.ts @@ -2,12 +2,22 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { detectPlatform } from "../../utils/platform-detect"; +import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), - url: z.string().describe("URL or URL scheme to open (e.g. https://example.com or messages://)"), + udid: z + .string() + .describe( + "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." + ), + url: z + .string() + .describe( + "URL or scheme to open (e.g. https://example.com, messages://, tel://555, geo:37.0,-122.0)." + ), }); export const openUrlTool: ToolDefinition< @@ -15,19 +25,26 @@ export const openUrlTool: ToolDefinition< { opened: boolean; url: string } > = { id: "open-url", - description: `Open a URL or URL scheme on the simulator. -Use when you need to navigate to a web page or deep-link into an app. Returns { opened, url }. Fails if the URL scheme is not registered on the simulator. - -Common URL schemes: -- messages:// — Messages app -- settings:// — Settings app -- maps://?q= — Maps with a search query -- tel:// — Phone app -- mailto:
— Mail app -- https://... — Opens in Safari`, + description: `Open a URL or URL scheme on iOS or Android. +iOS: \`xcrun simctl openurl\`. +Android: \`am start -a android.intent.action.VIEW -d \`. +Common schemes work on both: https://, tel:, mailto:. iOS also: messages://, settings://, maps://. Android: geo:, plus any app-specific deep link. +Returns { opened, url }. Fails if no app is registered to handle the URI.`, zodSchema, services: () => ({}), async execute(_services, params) { + if (detectPlatform(params.udid) === "android") { + const quoted = `'${params.url.replace(/'/g, "'\\''")}'`; + const out = await adbShell( + params.udid, + `am start -a android.intent.action.VIEW -d ${quoted}`, + { timeoutMs: 15_000 } + ); + if (/Error:|No Activity found/i.test(out)) { + throw new Error(`open-url failed: ${out.trim()}`); + } + return { opened: true, url: params.url }; + } await execFileAsync("xcrun", ["simctl", "openurl", params.udid, params.url]); return { opened: true, url: params.url }; }, diff --git a/packages/tool-server/src/tools/simulator/reinstall-app.ts b/packages/tool-server/src/tools/simulator/reinstall-app.ts index 0ebd9881..629afcbd 100644 --- a/packages/tool-server/src/tools/simulator/reinstall-app.ts +++ b/packages/tool-server/src/tools/simulator/reinstall-app.ts @@ -1,21 +1,40 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { resolve as resolvePath } from "node:path"; import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { detectPlatform } from "../../utils/platform-detect"; +import { runAdb } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." + ), bundleId: z .string() .describe( - "App bundle identifier to uninstall (e.g. com.example.MyApp). Must match the app at appPath." + "iOS: bundle id to uninstall before installing. Android: package name (used only for clarity in the return payload; `adb install -r` identifies the app from the APK itself). Must match the app at appPath." ), appPath: z .string() .describe( - "Absolute or relative path to the .app bundle to install (e.g. ./build/Build/Products/Debug-iphonesimulator/MyApp.app)" + "Absolute path to the app bundle. iOS: `.app` directory (e.g. ./build/Build/Products/Debug-iphonesimulator/MyApp.app). Android: `.apk` file (e.g. android/app/build/outputs/apk/debug/app-debug.apk)." + ), + grantPermissions: z + .boolean() + .optional() + .describe( + "Android-only: auto-grant all runtime permissions on install (`adb install -g`). Ignored on iOS." + ), + allowDowngrade: z + .boolean() + .optional() + .describe( + "Android-only: allow installing a lower versionCode (`adb install -d`). Ignored on iOS." ), }); @@ -24,18 +43,33 @@ export const reinstallAppTool: ToolDefinition< { reinstalled: boolean; bundleId: string } > = { id: "reinstall-app", - description: `Register and install an app on the simulator by first uninstalling then installing from a .app bundle path. -Use for a full reinstall after rebuilding or to clear app data. Returns { reinstalled, bundleId }. Fails if the .app path does not exist or the bundle ID does not match.`, + description: `Install or reinstall an app on the device. +iOS: uninstalls the existing bundleId (if present), then \`xcrun simctl install\` from a .app path. Clears app data. +Android: \`adb install -r\` from an APK path. \`-r\` preserves data across installs; pass \`grantPermissions: true\` for \`-g\`. +Returns { reinstalled, bundleId }. Fails if the path does not exist or the package is malformed.`, zodSchema, services: () => ({}), async execute(_services, params) { const { udid, bundleId, appPath } = params; + const absolute = resolvePath(appPath); + if (detectPlatform(udid) === "android") { + const args = ["-s", udid, "install", "-r"]; + if (params.allowDowngrade) args.push("-d"); + if (params.grantPermissions) args.push("-g"); + args.push(absolute); + const { stdout, stderr } = await runAdb(args, { timeoutMs: 180_000 }); + const output = `${stdout}\n${stderr}`; + if (!/Success/i.test(output)) { + throw new Error(`adb install failed: ${output.trim()}`); + } + return { reinstalled: true, bundleId }; + } try { await execFileAsync("xcrun", ["simctl", "uninstall", udid, bundleId]); } catch { // App may not be installed — continue to install } - await execFileAsync("xcrun", ["simctl", "install", udid, appPath]); + await execFileAsync("xcrun", ["simctl", "install", udid, absolute]); return { reinstalled: true, bundleId }; }, }; diff --git a/packages/tool-server/src/tools/simulator/restart-app.ts b/packages/tool-server/src/tools/simulator/restart-app.ts index b40762bc..01272f8e 100644 --- a/packages/tool-server/src/tools/simulator/restart-app.ts +++ b/packages/tool-server/src/tools/simulator/restart-app.ts @@ -1,15 +1,21 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolDefinition } from "@argent/registry"; import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { detectPlatform } from "../../utils/platform-detect"; +import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), - bundleId: z.string().describe("App bundle identifier (e.g. com.apple.MobileSMS)"), + udid: z + .string() + .describe( + "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." + ), + bundleId: z.string().describe("App identifier. iOS: bundle id. Android: package name."), }); export const restartAppTool: ToolDefinition< @@ -17,14 +23,29 @@ export const restartAppTool: ToolDefinition< { restarted: boolean; bundleId: string } > = { id: "restart-app", - description: `Restart an app on the simulator by terminating then relaunching it by bundle ID. -Use when you need a clean in-memory state without a full reinstall. Also refreshes native-devtools launch injection before the relaunch. Returns { restarted, bundleId }. Fails if the bundle ID is not installed on the simulator.`, + description: `Restart an app by terminating then relaunching it. +iOS: \`xcrun simctl terminate\` + launch; refreshes native-devtools injection. +Android: \`am force-stop\` + \`monkey\` launcher intent. +Use when you need a clean in-memory state without a full reinstall. Returns { restarted, bundleId }. Fails if the app is not installed.`, zodSchema, - services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, - }), + services: (params): Record => + detectPlatform(params.udid) === "ios" + ? { nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}` } + : {}, async execute(services, params) { const { udid, bundleId } = params; + if (detectPlatform(udid) === "android") { + await adbShell(udid, `am force-stop ${bundleId}`, { timeoutMs: 15_000 }); + const out = await adbShell( + udid, + `monkey -p ${bundleId} -c android.intent.category.LAUNCHER 1`, + { timeoutMs: 30_000 } + ); + if (/No activities found|Error:/i.test(out)) { + throw new Error(`relaunch failed: ${out.trim()}`); + } + return { restarted: true, bundleId }; + } const api = services.nativeDevtools as NativeDevtoolsApi; await api.ensureEnvReady(); try { diff --git a/packages/tool-server/src/tools/simulator/rotate.ts b/packages/tool-server/src/tools/simulator/rotate.ts index 42c2a4ae..0154ed25 100644 --- a/packages/tool-server/src/tools/simulator/rotate.ts +++ b/packages/tool-server/src/tools/simulator/rotate.ts @@ -4,7 +4,11 @@ import type { SimulatorServerApi } from "../../blueprints/simulator-server"; import { sendCommand } from "../../utils/simulator-client"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .describe( + "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + ), orientation: z .enum(["Portrait", "LandscapeLeft", "LandscapeRight", "PortraitUpsideDown"]) .describe("Target orientation"), @@ -12,7 +16,7 @@ const zodSchema = z.object({ export const rotateTool: ToolDefinition, { orientation: string }> = { id: "rotate", - description: `Set the simulator orientation to Portrait, LandscapeLeft, LandscapeRight, or PortraitUpsideDown. Use when testing layout in a different orientation. Returns { orientation }. Fails if the simulator-server is not running for the given UDID.`, + description: `Set the device orientation to Portrait, LandscapeLeft, LandscapeRight, or PortraitUpsideDown. Works on iOS and Android via simulator-server. Re-run \`describe\` afterwards — frame coordinates change. Returns { orientation }. Fails if the simulator server cannot start.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/workspace/gather-workspace-data.ts b/packages/tool-server/src/tools/workspace/gather-workspace-data.ts index 0a4bbdf4..b1da9d4f 100644 --- a/packages/tool-server/src/tools/workspace/gather-workspace-data.ts +++ b/packages/tool-server/src/tools/workspace/gather-workspace-data.ts @@ -16,7 +16,9 @@ export const gatherWorkspaceDataTool: ToolDefinition< description: `Fetch a structured snapshot of a mobile app project's workspace. Returns package.json contents, metro/babel config text, app.json, eas.json, tsconfig, -platform directory presence (ios/, android/), lockfile type, .env file keys (no values), +platform directory presence (ios/, android/), Android applicationId parsed from +android/app/build.gradle(.kts), presence of android/gradlew (android_has_gradle), +iOS .xcworkspace name and Podfile presence, lockfile type, .env file keys (no values), installed CLI tool versions, scripts/ directory listing, husky hooks, CI config type, Makefile targets, lint-staged config, and a list of detected config files. diff --git a/packages/tool-server/src/utils/adb.ts b/packages/tool-server/src/utils/adb.ts new file mode 100644 index 00000000..bef40868 --- /dev/null +++ b/packages/tool-server/src/utils/adb.ts @@ -0,0 +1,173 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface AdbRunResult { + stdout: string; + stderr: string; +} + +/** + * Run `adb` directly. Callers that target a single device must pass `-s ` + * themselves via `args` — `runAdb` does not inject it, so a serial-less call + * will hit whichever device `ANDROID_SERIAL` / the default heuristic picks. + */ +export async function runAdb( + args: string[], + options: { timeoutMs?: number } = {} +): Promise { + const { stdout, stderr } = await execFileAsync("adb", args, { + timeout: options.timeoutMs ?? 30_000, + maxBuffer: 64 * 1024 * 1024, + encoding: "utf-8", + }); + return { stdout, stderr }; +} + +/** + * Run `adb` and return stdout as a Buffer — needed for binary payloads + * (screencap PNG bytes, uiautomator dump, etc.) where utf-8 decoding corrupts + * the stream. + */ +export async function runAdbBinary( + args: string[], + options: { timeoutMs?: number } = {} +): Promise { + const { stdout } = await execFileAsync("adb", args, { + timeout: options.timeoutMs ?? 30_000, + maxBuffer: 64 * 1024 * 1024, + encoding: "buffer", + }); + return stdout as unknown as Buffer; +} + +/** `adb -s shell ` with the shell command passed as a single argv entry. */ +export async function adbShell( + serial: string, + shellCommand: string, + options: { timeoutMs?: number } = {} +): Promise { + const { stdout } = await runAdb(["-s", serial, "shell", shellCommand], options); + return stdout; +} + +/** `adb -s exec-out ` — preserves stdout bytes for binary payloads. */ +export async function adbExecOutBinary( + serial: string, + shellCommand: string, + options: { timeoutMs?: number } = {} +): Promise { + return runAdbBinary(["-s", serial, "exec-out", shellCommand], options); +} + +export interface AndroidDevice { + serial: string; + state: string; + isEmulator: boolean; + model: string | null; + avdName: string | null; + sdkLevel: number | null; +} + +/** + * Parse the tab-separated output of `adb devices -l` into a list. Unauthorized + * and offline entries are kept in the list so the caller can surface them to the + * user — filter by `state === "device"` for ready-to-use devices. + */ +export function parseAdbDevices(stdout: string): Array<{ serial: string; state: string }> { + const devices: Array<{ serial: string; state: string }> = []; + const lines = stdout.split("\n"); + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("List of devices")) continue; + // Format: "\t" optionally followed by key:value pairs + const match = line.match(/^(\S+)\s+(\S+)/); + if (!match) continue; + devices.push({ serial: match[1]!, state: match[2]! }); + } + return devices; +} + +/** + * List all Android devices + emulators known to adb. + * `adb devices` alone returns just serial+state; this helper enriches each entry + * with model + AVD name + SDK level via targeted getprop calls. + */ +export async function listAndroidDevices(): Promise { + const { stdout } = await runAdb(["devices"]); + const basic = parseAdbDevices(stdout); + + const enriched = await Promise.all( + basic.map(async (d): Promise => { + if (d.state !== "device") { + return { + serial: d.serial, + state: d.state, + isEmulator: d.serial.startsWith("emulator-"), + model: null, + avdName: null, + sdkLevel: null, + }; + } + const [model, sdk, avd] = await Promise.all([ + adbShell(d.serial, "getprop ro.product.model").catch(() => ""), + adbShell(d.serial, "getprop ro.build.version.sdk").catch(() => ""), + // Emulator-only; returns empty on physical devices + adbShell(d.serial, "getprop ro.kernel.qemu.avd_name").catch(() => ""), + ]); + const sdkLevel = parseInt(sdk.trim(), 10); + return { + serial: d.serial, + state: d.state, + isEmulator: d.serial.startsWith("emulator-"), + model: model.trim() || null, + avdName: avd.trim() || null, + sdkLevel: Number.isFinite(sdkLevel) ? sdkLevel : null, + }; + }) + ); + return enriched; +} + +/** + * Block until a device is fully booted. `adb wait-for-device` only waits for the + * daemon connection; `sys.boot_completed=1` is the Android-canonical "fully booted" + * signal that package manager + activity manager are ready to receive commands. + */ +export async function waitForBootCompleted(serial: string, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const out = await adbShell(serial, "getprop sys.boot_completed", { timeoutMs: 3_000 }); + if (out.trim() === "1") return; + } catch { + // Device may be mid-boot; swallow and retry + } + await new Promise((r) => setTimeout(r, 1_000)); + } + throw new Error(`Timed out waiting for ${serial} to finish booting`); +} + +export interface AvdInfo { + name: string; +} + +/** List available AVDs via `emulator -list-avds`. Returns [] if emulator binary is unavailable. */ +export async function listAvds(): Promise { + try { + const { stdout } = await execFileAsync("emulator", ["-list-avds"], { timeout: 5_000 }); + return stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("INFO") && !l.startsWith("HAX")) + .map((name) => ({ name })); + } catch { + return []; + } +} + +/** Resolve the `emulator` binary path so we can spawn it detached. */ +export function emulatorBinaryName(): string { + return "emulator"; +} diff --git a/packages/tool-server/src/utils/android-screen.ts b/packages/tool-server/src/utils/android-screen.ts new file mode 100644 index 00000000..96fdca63 --- /dev/null +++ b/packages/tool-server/src/utils/android-screen.ts @@ -0,0 +1,39 @@ +import { adbShell } from "./adb"; + +export interface AndroidScreenSize { + width: number; + height: number; +} + +const cache = new Map(); +// Short TTL so a rotation triggered externally invalidates the cache within a +// few seconds. Only the `describe` tool needs the absolute pixel size (to +// normalize uiautomator bounds to 0–1), so the cost of a cache miss is low. +const CACHE_TTL_MS = 5_000; + +/** + * Read the device's current logical screen size via `wm size`. Cached briefly + * per serial. Used by `describe` to normalize uiautomator's absolute-pixel + * bounds into the 0–1 coordinate space shared with the rest of the tools. + * + * `wm size` reports "Physical size: WxH\nOverride size: WxH"; the override + * wins when present (set by emulators and some system configs). + */ +export async function getAndroidScreenSize(serial: string): Promise { + const cached = cache.get(serial); + if (cached && cached.expiresAt > Date.now()) return cached.size; + + const out = await adbShell(serial, "wm size", { timeoutMs: 5_000 }); + const override = out.match(/Override size:\s*(\d+)x(\d+)/); + const physical = out.match(/Physical size:\s*(\d+)x(\d+)/); + const match = override ?? physical; + if (!match) { + throw new Error(`Could not parse screen size from: ${out.trim()}`); + } + const size: AndroidScreenSize = { + width: parseInt(match[1]!, 10), + height: parseInt(match[2]!, 10), + }; + cache.set(serial, { size, expiresAt: Date.now() + CACHE_TTL_MS }); + return size; +} diff --git a/packages/tool-server/src/utils/platform-detect.ts b/packages/tool-server/src/utils/platform-detect.ts new file mode 100644 index 00000000..3f664ba5 --- /dev/null +++ b/packages/tool-server/src/utils/platform-detect.ts @@ -0,0 +1,23 @@ +export type Platform = "ios" | "android"; + +/** + * Classify a device id as an iOS Simulator UDID or an Android adb serial. + * + * iOS udids come in two shapes: + * - Classic UUID: `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (8-4-4-4-12 hex) + * - iOS 17+ short form: `XXXXXXXX-XXXXXXXXXXXXXXXX` (8-16 hex) + * + * Everything else — `emulator-5554`, `RF8M123`, `192.168.1.7:5555`, etc. — + * is treated as an Android adb serial. This is a lossy heuristic but it + * covers every real-world form we have seen and never misclassifies an iOS + * UDID as Android. + */ +export function detectPlatform(udid: string): Platform { + if (/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/.test(udid)) { + return "ios"; + } + if (/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/.test(udid)) { + return "ios"; + } + return "android"; +} diff --git a/packages/tool-server/src/utils/setup-registry.ts b/packages/tool-server/src/utils/setup-registry.ts index 739457dd..618c69d4 100644 --- a/packages/tool-server/src/utils/setup-registry.ts +++ b/packages/tool-server/src/utils/setup-registry.ts @@ -66,6 +66,10 @@ import { flowReadPrerequisiteTool } from "../tools/flows/flow-read-prerequisite" import { gatherWorkspaceDataTool } from "../tools/workspace/gather-workspace-data"; import { updateArgentTool } from "../tools/system/update-argent"; import { dismissUpdateTool } from "../tools/system/dismiss-update"; +import { androidListEmulatorsTool } from "../tools/android/android-list-emulators"; +import { androidBootEmulatorTool } from "../tools/android/android-boot-emulator"; +import { androidStopAppTool } from "../tools/android/android-stop-app"; +import { androidLogcatTool } from "../tools/android/android-logcat"; export function createRegistry(): Registry { const registry = new Registry(); @@ -145,5 +149,13 @@ export function createRegistry(): Registry { registry.registerTool(updateArgentTool); registry.registerTool(dismissUpdateTool); + // Android-only tools. Tools that exist on both platforms are exposed under + // their unified names above (screenshot, gesture-tap, describe, launch-app, + // etc.) and dispatch internally on udid shape; see utils/platform-detect.ts. + registry.registerTool(androidListEmulatorsTool); + registry.registerTool(androidBootEmulatorTool); + registry.registerTool(androidStopAppTool); + registry.registerTool(androidLogcatTool); + return registry; } diff --git a/packages/tool-server/src/utils/uiautomator-parser.ts b/packages/tool-server/src/utils/uiautomator-parser.ts new file mode 100644 index 00000000..62fc6aee --- /dev/null +++ b/packages/tool-server/src/utils/uiautomator-parser.ts @@ -0,0 +1,157 @@ +import type { DescribeNode } from "../tools/interactions/describe-contract"; + +interface ParsedXmlNode { + tag: string; + attrs: Record; + children: ParsedXmlNode[]; +} + +/** + * Minimal XML parser tuned for `uiautomator dump` output. The dump is always + * well-formed and shallow (attributes only, no CDATA), so a full XML parser would + * be overkill and add a dependency. + */ +export function parseUiAutomatorXml(xml: string): ParsedXmlNode | null { + const body = xml.replace(/^\s*<\?xml[^?]*\?>\s*/, ""); + // `s` flag so attribute lists can contain newlines; some Android builds wrap + // `uiautomator dump` output at ~1 KB boundaries. + const tagRe = /<(\/?)([A-Za-z_][\w.-]*)([^<>]*?)(\/?)>/gs; + const stack: ParsedXmlNode[] = []; + let root: ParsedXmlNode | null = null; + let match: RegExpExecArray | null; + while ((match = tagRe.exec(body)) !== null) { + const [, closing, tag, rawAttrs, selfClose] = match; + if (closing) { + stack.pop(); + continue; + } + const attrs = parseAttributes(rawAttrs ?? ""); + const node: ParsedXmlNode = { tag: tag!, attrs, children: [] }; + const parent = stack[stack.length - 1]; + if (parent) parent.children.push(node); + else root = node; + if (!selfClose) stack.push(node); + } + return root; +} + +function parseAttributes(raw: string): Record { + const attrs: Record = {}; + const re = /([A-Za-z_][\w.-]*)\s*=\s*"([^"]*)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(raw)) !== null) { + attrs[m[1]!] = decodeXmlEntities(m[2]!); + } + return attrs; +} + +function decodeXmlEntities(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +export function parseUiAutomatorBounds( + bounds: string +): { x: number; y: number; w: number; h: number } | null { + const m = bounds.match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/); + if (!m) return null; + const x1 = parseInt(m[1]!, 10); + const y1 = parseInt(m[2]!, 10); + const x2 = parseInt(m[3]!, 10); + const y2 = parseInt(m[4]!, 10); + return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) }; +} + +export function deriveUiAutomatorRole(className: string): string { + const short = className.split(".").pop() ?? className; + const lower = short.toLowerCase(); + // Order matters: RadioButton and CheckBox both contain "button"/"box" as substrings + // of more specific classes, so check the specific cases first. + if (lower.includes("radiobutton")) return "RadioButton"; + if (lower.includes("checkbox")) return "CheckBox"; + if (lower.includes("button")) return "Button"; + if (lower.includes("edittext") || lower.includes("textinput")) return "TextField"; + if (lower.includes("textview") || lower === "text") return "StaticText"; + if (lower.includes("image")) return "Image"; + if (lower.includes("switch")) return "Switch"; + if (lower.includes("scrollview") || lower.includes("recyclerview") || lower.includes("listview")) + return "ScrollView"; + if (lower.includes("webview")) return "WebView"; + return short || "View"; +} + +/** + * Convert a parsed `` element into a `DescribeNode` with normalized frame + * coordinates. Returns `null` when the node has no bounds AND no useful children. + */ +export function convertUiAutomatorNode( + n: ParsedXmlNode, + screenW: number, + screenH: number +): DescribeNode | null { + if (n.tag !== "node") return null; + + const attrs = n.attrs; + const bounds = parseUiAutomatorBounds(attrs.bounds ?? ""); + const children: DescribeNode[] = []; + for (const c of n.children) { + const converted = convertUiAutomatorNode(c, screenW, screenH); + if (converted) children.push(converted); + } + + if (!bounds) { + return children.length === 1 ? children[0]! : null; + } + + const frame = { + x: screenW > 0 ? Math.max(0, Math.min(1, bounds.x / screenW)) : 0, + y: screenH > 0 ? Math.max(0, Math.min(1, bounds.y / screenH)) : 0, + width: screenW > 0 ? Math.max(0, Math.min(1, bounds.w / screenW)) : 0, + height: screenH > 0 ? Math.max(0, Math.min(1, bounds.h / screenH)) : 0, + }; + + const node: DescribeNode = { + role: deriveUiAutomatorRole(attrs.class ?? ""), + frame, + children, + }; + const label = attrs["content-desc"] || attrs.text || undefined; + if (label) node.label = label; + const identifier = attrs["resource-id"] || undefined; + if (identifier) node.identifier = identifier; + if (attrs.text && label !== attrs.text) node.value = attrs.text; + + return node; +} + +/** + * Parse a full `uiautomator dump` output into a DescribeNode tree matching the + * iOS describe contract, so the same agent guidance about frames + tap points applies. + */ +export function parseUiAutomatorDump( + rawOutput: string, + screenW: number, + screenH: number +): DescribeNode { + let xml = rawOutput; + const xmlEnd = xml.lastIndexOf(""); + if (xmlEnd !== -1) xml = xml.slice(0, xmlEnd + "".length); + const root = parseUiAutomatorXml(xml); + if (!root) { + throw new Error("Failed to parse uiautomator dump output"); + } + const topChildren: DescribeNode[] = []; + for (const c of root.children) { + const converted = convertUiAutomatorNode(c, screenW, screenH); + if (converted) topChildren.push(converted); + } + return { + role: "Screen", + frame: { x: 0, y: 0, width: 1, height: 1 }, + children: topChildren, + }; +} diff --git a/packages/tool-server/src/utils/workspace-reader.ts b/packages/tool-server/src/utils/workspace-reader.ts index 7f1e0f14..0ed78db6 100644 --- a/packages/tool-server/src/utils/workspace-reader.ts +++ b/packages/tool-server/src/utils/workspace-reader.ts @@ -25,6 +25,8 @@ export interface WorkspaceSnapshot { has_android_dir: boolean; ios_workspace: string | null; has_podfile: boolean; + android_application_id: string | null; + android_has_gradle: boolean; lockfile: "yarn.lock" | "package-lock.json" | "pnpm-lock.yaml" | "bun.lockb" | "bun.lock" | null; @@ -181,6 +183,27 @@ async function findIosWorkspace(iosDir: string): Promise { return ws ?? null; } +// ── Android application id detection ──────────────────────────────── + +/** + * Extract `applicationId` from the `:app` module's `build.gradle` or `build.gradle.kts`. + * Handles both `applicationId "com.x"` (Groovy) and `applicationId = "com.x"` (Kotlin DSL). + * Returns null when the file is missing or no applicationId line is found. + */ +async function detectAndroidApplicationId(androidDir: string): Promise { + const candidates = [ + join(androidDir, "app", "build.gradle"), + join(androidDir, "app", "build.gradle.kts"), + ]; + for (const path of candidates) { + const text = await readTextFile(path); + if (!text) continue; + const match = text.match(/applicationId\s*=?\s*["']([^"']+)["']/); + if (match) return match[1]!; + } + return null; +} + // ── CI config detection ────────────────────────────────────────────── const CI_CONFIGS = [ @@ -366,11 +389,21 @@ export async function readWorkspaceSnapshot(workspacePath: string): Promise { + it("parses a typical `adb devices` output", () => { + const stdout = [ + "List of devices attached", + "emulator-5554\tdevice", + "R5CT12345678\tdevice", + "", + ].join("\n"); + expect(parseAdbDevices(stdout)).toEqual([ + { serial: "emulator-5554", state: "device" }, + { serial: "R5CT12345678", state: "device" }, + ]); + }); + + it("includes offline and unauthorized devices with their state", () => { + const stdout = ["List of devices attached", "emulator-5554\toffline", "abc\tunauthorized"].join( + "\n" + ); + expect(parseAdbDevices(stdout)).toEqual([ + { serial: "emulator-5554", state: "offline" }, + { serial: "abc", state: "unauthorized" }, + ]); + }); + + it("ignores blank lines and the header only", () => { + expect(parseAdbDevices("List of devices attached\n\n")).toEqual([]); + }); + + it("tolerates `-l` suffix metadata after state", () => { + const stdout = [ + "List of devices attached", + "emulator-5554\tdevice product:sdk_gphone64_arm64 model:sdk_gphone64_arm64", + ].join("\n"); + expect(parseAdbDevices(stdout)).toEqual([{ serial: "emulator-5554", state: "device" }]); + }); +}); diff --git a/packages/tool-server/test/android-describe-screen.test.ts b/packages/tool-server/test/android-describe-screen.test.ts new file mode 100644 index 00000000..9ae29ac9 --- /dev/null +++ b/packages/tool-server/test/android-describe-screen.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { parseDescribeResult } from "../src/tools/interactions/describe-contract"; +import { + deriveUiAutomatorRole, + parseUiAutomatorBounds, + parseUiAutomatorDump, +} from "../src/utils/uiautomator-parser"; + +describe("parseUiAutomatorBounds", () => { + it("parses [x1,y1][x2,y2]", () => { + expect(parseUiAutomatorBounds("[0,0][1080,1920]")).toEqual({ + x: 0, + y: 0, + w: 1080, + h: 1920, + }); + }); + + it("handles non-zero origins", () => { + expect(parseUiAutomatorBounds("[100,200][400,800]")).toEqual({ + x: 100, + y: 200, + w: 300, + h: 600, + }); + }); + + it("returns null for unparseable input", () => { + expect(parseUiAutomatorBounds("garbage")).toBeNull(); + }); +}); + +describe("deriveUiAutomatorRole", () => { + const cases: Array<[string, string]> = [ + ["android.widget.Button", "Button"], + ["android.widget.ImageButton", "Button"], + ["android.widget.EditText", "TextField"], + ["android.widget.TextView", "StaticText"], + ["android.widget.ImageView", "Image"], + ["android.widget.Switch", "Switch"], + ["android.widget.CheckBox", "CheckBox"], + ["android.widget.RadioButton", "RadioButton"], + ["androidx.recyclerview.widget.RecyclerView", "ScrollView"], + ["android.webkit.WebView", "WebView"], + ["", "View"], + ["com.example.CustomWidget", "CustomWidget"], + ]; + for (const [input, expected] of cases) { + it(`maps ${input || "(empty)"} → ${expected}`, () => { + expect(deriveUiAutomatorRole(input)).toBe(expected); + }); + } +}); + +describe("parseUiAutomatorDump", () => { + const sampleXml = ` + + + + + + +`; + + it("returns a synthetic Screen root with full-screen frame", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + expect(tree.role).toBe("Screen"); + expect(tree.frame).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + expect(tree.children).toHaveLength(1); // FrameLayout root + }); + + it("normalizes pixel bounds to 0–1 using the provided screen size", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + // Dive into the FrameLayout → first child (the TextView with "Sign in") + const frame = tree.children[0]!.children[0]!.frame; + expect(frame.x).toBeCloseTo(100 / 1080, 3); + expect(frame.y).toBeCloseTo(200 / 1920, 3); + expect(frame.width).toBeCloseTo((980 - 100) / 1080, 3); + expect(frame.height).toBeCloseTo((280 - 200) / 1920, 3); + }); + + it("maps class → role and populates label/identifier/value appropriately", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + const children = tree.children[0]!.children; + const title = children[0]!; + const email = children[1]!; + const submit = children[2]!; + + expect(title.role).toBe("StaticText"); + expect(title.label).toBe("Sign in"); + expect(title.identifier).toBe("com.example.app:id/title"); + + expect(email.role).toBe("TextField"); + expect(email.label).toBe("Email address"); // content-desc wins over empty text + expect(email.value).toBeUndefined(); + + expect(submit.role).toBe("Button"); + expect(submit.label).toBe("Submit"); // text is used when content-desc is empty + }); + + it("produces output matching the shared DescribeNode schema", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + expect(() => parseDescribeResult(tree)).not.toThrow(); + }); + + it("strips the trailing `UI hierchary dumped to:` status line from the raw dump", () => { + const withTrailer = sampleXml + "\nUI hierchary dumped to: /dev/tty\n"; + const tree = parseUiAutomatorDump(withTrailer, 1080, 1920); + expect(tree.children).toHaveLength(1); + }); + + it("returns a zero-frame value when the screen size is zero (defensive)", () => { + const tree = parseUiAutomatorDump(sampleXml, 0, 0); + expect(tree.children[0]!.frame).toEqual({ x: 0, y: 0, width: 0, height: 0 }); + }); +}); diff --git a/packages/tool-server/test/boot-simulator.test.ts b/packages/tool-server/test/boot-simulator.test.ts index 3956d43e..cd52a14b 100644 --- a/packages/tool-server/test/boot-simulator.test.ts +++ b/packages/tool-server/test/boot-simulator.test.ts @@ -36,18 +36,30 @@ describe("boot-simulator tool", () => { const tool = createBootSimulatorTool(registry); - await expect(tool.execute!({}, { udid: "SIM-1" })).resolves.toEqual({ - udid: "SIM-1", + await expect( + tool.execute!({}, { udid: "11111111-1111-1111-1111-111111111111" }) + ).resolves.toEqual({ + udid: "11111111-1111-1111-1111-111111111111", booted: true, }); expect(mockExecFile.mock.calls.map(([file, args]) => [file, args])).toEqual([ - ["xcrun", ["simctl", "boot", "SIM-1"]], - ["xcrun", ["simctl", "bootstatus", "SIM-1", "-b"]], - ["defaults", ["write", "com.apple.iphonesimulator", "CurrentDeviceUDID", "SIM-1"]], + ["xcrun", ["simctl", "boot", "11111111-1111-1111-1111-111111111111"]], + ["xcrun", ["simctl", "bootstatus", "11111111-1111-1111-1111-111111111111", "-b"]], + [ + "defaults", + [ + "write", + "com.apple.iphonesimulator", + "CurrentDeviceUDID", + "11111111-1111-1111-1111-111111111111", + ], + ], ["open", ["-a", "Simulator.app"]], ]); - expect(resolveService).toHaveBeenCalledWith("NativeDevtools:SIM-1"); + expect(resolveService).toHaveBeenCalledWith( + "NativeDevtools:11111111-1111-1111-1111-111111111111" + ); expect(resolveService.mock.invocationCallOrder[0]).toBeGreaterThan( mockExecFile.mock.invocationCallOrder[1] ); @@ -72,15 +84,19 @@ describe("boot-simulator tool", () => { const tool = createBootSimulatorTool(registry); - await expect(tool.execute!({}, { udid: "SIM-2" })).resolves.toEqual({ - udid: "SIM-2", + await expect( + tool.execute!({}, { udid: "22222222-2222-2222-2222-222222222222" }) + ).resolves.toEqual({ + udid: "22222222-2222-2222-2222-222222222222", booted: true, }); expect(mockExecFile.mock.calls[1]?.slice(0, 2)).toEqual([ "xcrun", - ["simctl", "bootstatus", "SIM-2", "-b"], + ["simctl", "bootstatus", "22222222-2222-2222-2222-222222222222", "-b"], ]); - expect(resolveService).toHaveBeenCalledWith("NativeDevtools:SIM-2"); + expect(resolveService).toHaveBeenCalledWith( + "NativeDevtools:22222222-2222-2222-2222-222222222222" + ); }); }); diff --git a/packages/tool-server/test/describe-android-dispatch.test.ts b/packages/tool-server/test/describe-android-dispatch.test.ts new file mode 100644 index 00000000..52206079 --- /dev/null +++ b/packages/tool-server/test/describe-android-dispatch.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Registry } from "@argent/registry"; + +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string | Buffer; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { createDescribeTool } from "../src/tools/interactions/describe"; + +const fakeRegistry: Registry = { + resolveService: vi.fn(), +} as unknown as Registry; + +// Each test gets a unique serial because `getAndroidScreenSize` caches the +// `wm size` output for 5 s per serial. Reusing a serial across tests leaks the +// first test's mocked screen size into the second. +let nextSerial = 7000; +const mkSerial = () => `emulator-${nextSerial++}`; + +beforeEach(() => { + execFileMock.mockReset(); +}); + +function sampleDump(): string { + return ` + + +`; +} + +describe("describe — Android branch dispatch on adb serial", () => { + it("calls `adb exec-out uiautomator dump ... && cat ...` and normalizes bounds using wm size", async () => { + // Sequence of adb calls this branch makes: + // 1. adb -s shell wm size -> screen size for normalization + // 2. adb -s exec-out uiautomator... -> the XML dump + const calls: string[][] = []; + execFileMock.mockImplementation((cmd: string, args: string[]) => { + calls.push([cmd, ...args]); + const joined = args.join(" "); + if (joined.includes("wm size")) { + return { stdout: "Physical size: 1080x1920\n", stderr: "" }; + } + if (joined.includes("uiautomator dump")) { + // Buffer is what exec-out returns for binary-safe payloads; we return + // a Buffer here to mirror production. + return { stdout: Buffer.from(sampleDump(), "utf-8"), stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + const tool = createDescribeTool(fakeRegistry); + const serial = mkSerial(); + const result = await tool.execute({}, { udid: serial }); + + expect(result.source).toBe("native-devtools"); + expect(result.tree.role).toBe("Screen"); + expect(result.tree.children).toHaveLength(1); + + // Registry.resolveService must not be touched on Android — the AX-service + // and native-devtools blueprints are iOS-only. + expect(fakeRegistry.resolveService).not.toHaveBeenCalled(); + + // Both adb calls must target -s . Any missing -s means commands + // could leak onto a second attached device. + const adbCalls = calls.filter((c) => c[0] === "adb"); + expect(adbCalls.length).toBeGreaterThanOrEqual(2); + for (const c of adbCalls) { + expect(c).toContain("-s"); + expect(c).toContain(serial); + } + }); + + it("prefers the `Override size` line when both Physical and Override are present", async () => { + // Emulators commonly set an override — the tool must read it, not the + // physical size, otherwise tap coordinates render at the wrong fraction. + // Use a dump with small bounds so the numerator stays well below both + // denominators; the resulting fraction is only correct when the override + // wins (physical would produce a *different* fraction). + const dump = ` + + +`; + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (args.join(" ").includes("wm size")) { + return { + stdout: "Physical size: 1080x1920\nOverride size: 540x960\n", + stderr: "", + }; + } + if (args.join(" ").includes("uiautomator dump")) { + return { stdout: Buffer.from(dump, "utf-8"), stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + const tool = createDescribeTool(fakeRegistry); + const serial = mkSerial(); + const result = await tool.execute({}, { udid: serial }); + const node = result.tree.children[0]!; + // bounds [108,96][216,192] against override 540x960 → (0.2, 0.1, 0.2, 0.1). + // Against physical 1080x1920 → (0.1, 0.05, 0.1, 0.05). + // The values below prove the code picked the override, not the physical. + expect(node.frame.x).toBeCloseTo(108 / 540, 3); + expect(node.frame.y).toBeCloseTo(96 / 960, 3); + expect(node.frame.width).toBeCloseTo(108 / 540, 3); + expect(node.frame.height).toBeCloseTo(96 / 960, 3); + }); + + it("surfaces a helpful error when the dump fails with a keyguard/secure overlay", async () => { + // Repro of the specific failure mode we saw on a locked emulator — the + // error message must mention the common causes so an agent can recover. + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (args.join(" ").includes("wm size")) { + return { stdout: "Physical size: 1080x1920\n", stderr: "" }; + } + return { + stdout: Buffer.from("ERROR: could not get idle state.\n", "utf-8"), + stderr: "", + }; + }); + + const tool = createDescribeTool(fakeRegistry); + await expect(tool.execute({}, { udid: mkSerial() })).rejects.toThrow( + /uiautomator could not capture/ + ); + }); + + it("ignores a bundleId arg on Android (iOS-only hint)", async () => { + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (args.join(" ").includes("wm size")) { + return { stdout: "Physical size: 1080x1920\n", stderr: "" }; + } + return { stdout: Buffer.from(sampleDump(), "utf-8"), stderr: "" }; + }); + const tool = createDescribeTool(fakeRegistry); + const result = await tool.execute({}, { udid: mkSerial(), bundleId: "com.example.app" }); + expect(result.source).toBe("native-devtools"); + // bundleId must not have caused any extra adb or xcrun calls beyond + // wm-size + uiautomator-dump. + expect(execFileMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/tool-server/test/describe-tool.test.ts b/packages/tool-server/test/describe-tool.test.ts index 37d34dd6..f1a0e9c0 100644 --- a/packages/tool-server/test/describe-tool.test.ts +++ b/packages/tool-server/test/describe-tool.test.ts @@ -82,7 +82,7 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1" }); + const result = await tool.execute({}, { udid: "11111111-1111-1111-1111-111111111111" }); expect(result.source).toBe("ax-service"); expect(result.tree.role).toBe("AXGroup"); expect(result.tree.children[0]?.label).toBe("General"); @@ -110,7 +110,7 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1" }); + const result = await tool.execute({}, { udid: "11111111-1111-1111-1111-111111111111" }); expect(result.source).toBe("ax-service"); expect(result.tree.children).toHaveLength(2); expect(result.tree.children[0]?.label).toBe("Allow Once"); @@ -127,7 +127,7 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1" }); + const result = await tool.execute({}, { udid: "11111111-1111-1111-1111-111111111111" }); expect(result.source).toBe("ax-service"); expect(result.tree.role).toBe("AXGroup"); expect(result.tree.children).toHaveLength(0); @@ -159,7 +159,10 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1", bundleId: "com.apple.Preferences" }); + const result = await tool.execute( + {}, + { udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.apple.Preferences" } + ); expect(result.source).toBe("native-devtools"); expect(result.tree.children[0]?.label).toBe("General"); expect(result.tree.children[0]?.role).toBe("AXButton"); @@ -191,7 +194,7 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1" }); + const result = await tool.execute({}, { udid: "11111111-1111-1111-1111-111111111111" }); expect(result.source).toBe("native-devtools"); expect(result.tree.children[0]?.label).toBe("Hello World"); expect(result.should_restart).toBeUndefined(); @@ -211,7 +214,10 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1", bundleId: "com.example.app" }); + const result = await tool.execute( + {}, + { udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" } + ); expect(result.source).toBe("ax-service"); expect(result.should_restart).toBe(true); expect(result.tree.children).toHaveLength(0); @@ -227,7 +233,7 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1" }); + const result = await tool.execute({}, { udid: "11111111-1111-1111-1111-111111111111" }); expect(result.source).toBe("ax-service"); expect(result.tree.children).toHaveLength(0); expect(result.should_restart).toBeUndefined(); @@ -237,7 +243,9 @@ describe("describe tool", () => { const registry = makeMockRegistry({}); const tool = createDescribeTool(registry); - await expect(tool.execute({}, { udid: "SIM-1" })).rejects.toThrow("ax-service not available"); + await expect( + tool.execute({}, { udid: "11111111-1111-1111-1111-111111111111" }) + ).rejects.toThrow("ax-service not available"); }); it("returns multiple elements with correct roles", async () => { @@ -267,7 +275,7 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1" }); + const result = await tool.execute({}, { udid: "11111111-1111-1111-1111-111111111111" }); expect(result.source).toBe("ax-service"); expect(result.tree.children).toHaveLength(3); expect(result.tree.children[0]?.role).toBe("AXTextField"); @@ -292,8 +300,10 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi }); const tool = createDescribeTool(registry); - await tool.execute({}, { udid: "ABC-12345" }); - expect(registry.resolveService).toHaveBeenCalledWith("AXService:ABC-12345"); + await tool.execute({}, { udid: "11111111-2222-3333-4444-555555555555" }); + expect(registry.resolveService).toHaveBeenCalledWith( + "AXService:11111111-2222-3333-4444-555555555555" + ); }); it("returns empty AX result when native queryViewHierarchy returns an error", async () => { @@ -310,7 +320,10 @@ describe("describe tool", () => { const registry = makeMockRegistry({ axService: axApi, nativeDevtools: nativeApi }); const tool = createDescribeTool(registry); - const result = await tool.execute({}, { udid: "SIM-1", bundleId: "com.example.app" }); + const result = await tool.execute( + {}, + { udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" } + ); expect(result.source).toBe("ax-service"); expect(result.tree.children).toHaveLength(0); }); diff --git a/packages/tool-server/test/launch-app-dispatch.test.ts b/packages/tool-server/test/launch-app-dispatch.test.ts new file mode 100644 index 00000000..9afac65f --- /dev/null +++ b/packages/tool-server/test/launch-app-dispatch.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the child_process boundary so we don't actually shell out to xcrun / adb. +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + // promisify(execFile) calls it as `execFile(cmd, args, opts, cb)` — cb is the last arg. + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { launchAppTool } from "../src/tools/simulator/launch-app"; + +const iosUdid = "11111111-2222-3333-4444-555555555555"; +const androidSerial = "emulator-5554"; +const iosNativeApi = { ensureEnvReady: vi.fn().mockResolvedValue(undefined) }; + +beforeEach(() => { + execFileMock.mockReset().mockReturnValue({ stdout: "", stderr: "" }); + iosNativeApi.ensureEnvReady.mockClear().mockResolvedValue(undefined); +}); + +describe("launch-app.services — platform-dependent ServiceRef", () => { + it("requests the nativeDevtools service for iOS udids", () => { + expect(launchAppTool.services({ udid: iosUdid, bundleId: "com.example" })).toEqual({ + nativeDevtools: `NativeDevtools:${iosUdid}`, + }); + }); + + it("requests no services for Android serials — avoids spawning the iOS-only NativeDevtools service", () => { + // This is critical: NativeDevtools depends on xcrun simctl APIs and will + // blow up on non-UUID udids. A stray nativeDevtools request for an + // Android serial would break every Android launch. + expect(launchAppTool.services({ udid: androidSerial, bundleId: "com.example" })).toEqual({}); + }); +}); + +describe("launch-app.execute — iOS path (unchanged behavior)", () => { + it("prepares native devtools then calls `xcrun simctl launch`", async () => { + await launchAppTool.execute!( + { nativeDevtools: iosNativeApi }, + { udid: iosUdid, bundleId: "com.apple.Preferences" } + ); + + expect(iosNativeApi.ensureEnvReady).toHaveBeenCalledTimes(1); + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(execFileMock).toHaveBeenCalledWith( + "xcrun", + ["simctl", "launch", iosUdid, "com.apple.Preferences"], + undefined + ); + }); + + it("ensureEnvReady is awaited *before* launch (injection must be in place pre-spawn)", async () => { + const order: string[] = []; + iosNativeApi.ensureEnvReady.mockImplementation(async () => { + order.push("ensureEnvReady"); + }); + execFileMock.mockImplementation(() => { + order.push("xcrun"); + return { stdout: "", stderr: "" }; + }); + + await launchAppTool.execute!( + { nativeDevtools: iosNativeApi }, + { udid: iosUdid, bundleId: "com.apple.Preferences" } + ); + + expect(order).toEqual(["ensureEnvReady", "xcrun"]); + }); + + it("ignores an `activity` arg on iOS (Android-only parameter)", async () => { + await launchAppTool.execute!( + { nativeDevtools: iosNativeApi }, + { udid: iosUdid, bundleId: "com.apple.Preferences", activity: ".Root" } + ); + expect(execFileMock).toHaveBeenCalledWith( + "xcrun", + ["simctl", "launch", iosUdid, "com.apple.Preferences"], + undefined + ); + }); +}); + +describe("launch-app.execute — Android path", () => { + it("defaults to `monkey` LAUNCHER intent when no activity is provided", async () => { + await launchAppTool.execute!({}, { udid: androidSerial, bundleId: "com.android.settings" }); + expect(execFileMock).toHaveBeenCalledWith( + "adb", + [ + "-s", + androidSerial, + "shell", + "monkey -p com.android.settings -c android.intent.category.LAUNCHER 1", + ], + expect.any(Object) + ); + // Critically, NO xcrun call — running iOS tooling for an Android device is + // the exact class of regression this test guards against. + expect(execFileMock).not.toHaveBeenCalledWith("xcrun", expect.anything(), expect.anything()); + }); + + it("uses `am start -W -n pkg/.Activity` when activity starts with a dot", async () => { + await launchAppTool.execute!( + {}, + { udid: androidSerial, bundleId: "com.example.app", activity: ".MainActivity" } + ); + expect(execFileMock).toHaveBeenCalledWith( + "adb", + ["-s", androidSerial, "shell", "am start -W -n com.example.app/.MainActivity"], + expect.any(Object) + ); + }); + + it("passes pre-qualified `pkg/.Activity` strings through unchanged", async () => { + await launchAppTool.execute!( + {}, + { + udid: androidSerial, + bundleId: "com.example.app", + activity: "com.example.app/com.example.app.MainActivity", + } + ); + expect(execFileMock).toHaveBeenCalledWith( + "adb", + ["-s", androidSerial, "shell", "am start -W -n com.example.app/com.example.app.MainActivity"], + expect.any(Object) + ); + }); + + it("throws when am start reports an error (no Activity found)", async () => { + execFileMock.mockReturnValue({ + stdout: "Error: Activity class {com.foo/.Bar} does not exist.", + stderr: "", + }); + await expect( + launchAppTool.execute!({}, { udid: androidSerial, bundleId: "com.foo", activity: ".Bar" }) + ).rejects.toThrow(/am start failed/); + }); + + it("throws when monkey can't find a launcher activity", async () => { + execFileMock.mockReturnValue({ + stdout: "** No activities found to run, monkey aborted.", + stderr: "", + }); + await expect( + launchAppTool.execute!({}, { udid: androidSerial, bundleId: "com.not.installed" }) + ).rejects.toThrow(/monkey launch failed/); + }); +}); diff --git a/packages/tool-server/test/native-devtools-status.test.ts b/packages/tool-server/test/native-devtools-status.test.ts index 4afb4d29..26580301 100644 --- a/packages/tool-server/test/native-devtools-status.test.ts +++ b/packages/tool-server/test/native-devtools-status.test.ts @@ -43,7 +43,7 @@ describe("native-devtools-status tool", () => { await expect( nativeDevtoolsStatusTool.execute( { nativeDevtools: api }, - { udid: "SIM-1", bundleId: "com.example.app" } + { udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" } ) ).resolves.toEqual({ envSetup: true, @@ -62,7 +62,7 @@ describe("native-devtools-status tool", () => { await expect( nativeDevtoolsStatusTool.execute( { nativeDevtools: api }, - { udid: "SIM-1", bundleId: "com.example.app" } + { udid: "11111111-1111-1111-1111-111111111111", bundleId: "com.example.app" } ) ).resolves.toEqual({ envSetup: true, diff --git a/packages/tool-server/test/open-url-dispatch.test.ts b/packages/tool-server/test/open-url-dispatch.test.ts new file mode 100644 index 00000000..dae0ad8f --- /dev/null +++ b/packages/tool-server/test/open-url-dispatch.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { openUrlTool } from "../src/tools/simulator/open-url"; + +const iosUdid = "11111111-2222-3333-4444-555555555555"; +const androidSerial = "emulator-5554"; + +beforeEach(() => { + execFileMock.mockReset().mockReturnValue({ stdout: "", stderr: "" }); +}); + +describe("open-url — iOS path (unchanged)", () => { + it("calls `xcrun simctl openurl` with the URL verbatim, no shell escaping", async () => { + await openUrlTool.execute!({}, { udid: iosUdid, url: "https://example.com" }); + expect(execFileMock).toHaveBeenCalledWith( + "xcrun", + ["simctl", "openurl", iosUdid, "https://example.com"], + undefined + ); + }); + + it("passes app schemes through untouched", async () => { + await openUrlTool.execute!({}, { udid: iosUdid, url: "messages://" }); + expect(execFileMock).toHaveBeenCalledWith( + "xcrun", + ["simctl", "openurl", iosUdid, "messages://"], + undefined + ); + }); + + it("does not shell-wrap iOS URLs — execFile avoids the shell, so adding quotes would be wrong", async () => { + // `simctl openurl` expects the raw URL as an argv value. If we accidentally + // wrapped the URL in quotes like the Android branch does, iOS would receive + // a literally-quoted URL and fail. This asserts the iOS branch sends the + // URL verbatim — any prefix/suffix `'` would mean the quoting regressed. + const url = "https://example.com/?q=it's"; + await openUrlTool.execute!({}, { udid: iosUdid, url }); + const args = execFileMock.mock.calls[0]![1] as string[]; + expect(args[3]).toBe(url); + expect(args[3]!.startsWith("'")).toBe(false); + expect(args[3]!.endsWith("'")).toBe(false); + }); +}); + +describe("open-url — Android path", () => { + it("routes through `am start -a VIEW -d ` via adb shell", async () => { + await openUrlTool.execute!({}, { udid: androidSerial, url: "https://example.com" }); + expect(execFileMock).toHaveBeenCalledWith( + "adb", + [ + "-s", + androidSerial, + "shell", + "am start -a android.intent.action.VIEW -d 'https://example.com'", + ], + expect.any(Object) + ); + }); + + it("shell-escapes single quotes in the URL", async () => { + // adb shell interprets the argument as a single shell string, so any + // embedded `'` must be escaped as `'\''`. If this regresses, URLs with + // apostrophes will crash `am start` with a syntax error. + await openUrlTool.execute!( + {}, + { udid: androidSerial, url: "https://example.com/path/it's-here" } + ); + const call = execFileMock.mock.calls[0]![1] as string[]; + const shellCommand = call[3]!; + expect(shellCommand.includes(`'\\''`)).toBe(true); + expect(shellCommand).toBe( + `am start -a android.intent.action.VIEW -d 'https://example.com/path/it'\\''s-here'` + ); + }); + + it("throws when `am start` surfaces an error for an unhandled scheme", async () => { + execFileMock.mockReturnValue({ + stdout: "Error: Activity not started, unable to resolve Intent", + stderr: "", + }); + await expect( + openUrlTool.execute!({}, { udid: androidSerial, url: "custom-scheme://unknown" }) + ).rejects.toThrow(/open-url failed/); + }); + + it("rejects `No Activity found` output", async () => { + execFileMock.mockReturnValue({ + stdout: "No Activity found to handle Intent { VIEW dat=... }", + stderr: "", + }); + await expect( + openUrlTool.execute!({}, { udid: androidSerial, url: "custom-scheme://x" }) + ).rejects.toThrow(/open-url failed/); + }); +}); + +describe("open-url.services", () => { + it("never requests a service — both code paths are self-contained", () => { + // Neither xcrun nor adb depend on a registry-managed service, so this + // tool stays service-less. If a future change adds a service dependency, + // update this test deliberately. + expect(openUrlTool.services({ udid: iosUdid, url: "https://x" })).toEqual({}); + expect(openUrlTool.services({ udid: androidSerial, url: "https://x" })).toEqual({}); + }); +}); diff --git a/packages/tool-server/test/platform-detect.test.ts b/packages/tool-server/test/platform-detect.test.ts new file mode 100644 index 00000000..0686bd6f --- /dev/null +++ b/packages/tool-server/test/platform-detect.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { detectPlatform } from "../src/utils/platform-detect"; + +describe("detectPlatform", () => { + it("recognizes the classic iOS UDID (8-4-4-4-12 hex)", () => { + expect(detectPlatform("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")).toBe("ios"); + expect(detectPlatform("00000000-0000-0000-0000-000000000000")).toBe("ios"); + // Any case works. + expect(detectPlatform("abcdef12-3456-7890-abcd-ef1234567890")).toBe("ios"); + }); + + it("recognizes the iOS 17+ short UDID (8-16 hex)", () => { + expect(detectPlatform("00008030-001C25120C22802E")).toBe("ios"); + expect(detectPlatform("ffffffff-0000000000000000")).toBe("ios"); + }); + + it("treats Android emulator serials as android", () => { + expect(detectPlatform("emulator-5554")).toBe("android"); + expect(detectPlatform("emulator-5556")).toBe("android"); + }); + + it("treats physical Android serials as android", () => { + expect(detectPlatform("R5CT12345678")).toBe("android"); + expect(detectPlatform("HT7901A01234")).toBe("android"); + }); + + it("treats Android network serials (host:port) as android", () => { + expect(detectPlatform("192.168.1.50:5555")).toBe("android"); + }); + + it("treats malformed or short ids as android (safe default — iOS simctl would reject them immediately anyway)", () => { + expect(detectPlatform("ABC")).toBe("android"); + expect(detectPlatform("")).toBe("android"); + expect(detectPlatform("12345")).toBe("android"); + }); + + it("does not misclassify a UDID with non-hex characters as iOS", () => { + // Shape matches 8-4-4-4-12 but contains a non-hex char (G) + expect(detectPlatform("GGGGGGGG-1111-2222-3333-444444444444")).toBe("android"); + }); +}); diff --git a/packages/tool-server/test/reinstall-app-dispatch.test.ts b/packages/tool-server/test/reinstall-app-dispatch.test.ts new file mode 100644 index 00000000..e950b4cc --- /dev/null +++ b/packages/tool-server/test/reinstall-app-dispatch.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolve as resolvePath } from "node:path"; + +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { reinstallAppTool } from "../src/tools/simulator/reinstall-app"; + +const iosUdid = "11111111-2222-3333-4444-555555555555"; +const androidSerial = "emulator-5554"; + +beforeEach(() => { + execFileMock.mockReset().mockReturnValue({ stdout: "", stderr: "" }); +}); + +describe("reinstall-app — iOS path (unchanged semantics)", () => { + it("uninstalls then installs — order matters so the app data is wiped", async () => { + await reinstallAppTool.execute!( + {}, + { udid: iosUdid, bundleId: "com.example.MyApp", appPath: "/abs/MyApp.app" } + ); + expect(execFileMock).toHaveBeenCalledTimes(2); + expect(execFileMock.mock.calls[0]![0]).toBe("xcrun"); + expect(execFileMock.mock.calls[0]![1]).toEqual([ + "simctl", + "uninstall", + iosUdid, + "com.example.MyApp", + ]); + expect(execFileMock.mock.calls[1]![1]).toEqual([ + "simctl", + "install", + iosUdid, + "/abs/MyApp.app", + ]); + }); + + it("keeps going when uninstall fails — first-install scenario must not error", async () => { + let call = 0; + execFileMock.mockImplementation(() => { + call += 1; + if (call === 1) return new Error("simctl uninstall: app not installed"); + return { stdout: "", stderr: "" }; + }); + + const result = await reinstallAppTool.execute!( + {}, + { udid: iosUdid, bundleId: "com.new.App", appPath: "/abs/NewApp.app" } + ); + expect(result).toEqual({ reinstalled: true, bundleId: "com.new.App" }); + expect(execFileMock).toHaveBeenCalledTimes(2); // uninstall+install still both attempted + }); + + it("resolves relative iOS paths to absolute before handing them to simctl", async () => { + // This was added because Android's `adb install` needs an absolute path — + // we apply `resolvePath` outside the platform branch. Semantically iOS is + // unchanged because execFile already inherits `process.cwd()`, but the + // argument simctl sees is now the absolute form. Regressing this to a + // relative path would be fine for iOS but break Android, so we pin it. + await reinstallAppTool.execute!( + {}, + { udid: iosUdid, bundleId: "com.example.MyApp", appPath: "./build/MyApp.app" } + ); + const installCall = execFileMock.mock.calls[1]![1] as string[]; + expect(installCall[3]).toBe(resolvePath("./build/MyApp.app")); + expect(installCall[3]!.startsWith("/")).toBe(true); + }); + + it("ignores Android-only options — `grantPermissions` and `allowDowngrade` must not leak into simctl", async () => { + await reinstallAppTool.execute!( + {}, + { + udid: iosUdid, + bundleId: "com.example.MyApp", + appPath: "/abs/MyApp.app", + grantPermissions: true, + allowDowngrade: true, + } + ); + const installArgs = execFileMock.mock.calls[1]![1] as string[]; + expect(installArgs).toEqual(["simctl", "install", iosUdid, "/abs/MyApp.app"]); + expect(installArgs).not.toContain("-g"); + expect(installArgs).not.toContain("-d"); + }); +}); + +describe("reinstall-app — Android path", () => { + it("runs `adb -s install -r ` and reports success", async () => { + execFileMock.mockReturnValue({ stdout: "Success\n", stderr: "" }); + + const result = await reinstallAppTool.execute!( + {}, + { udid: androidSerial, bundleId: "com.example.app", appPath: "/abs/app.apk" } + ); + + expect(result).toEqual({ reinstalled: true, bundleId: "com.example.app" }); + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(execFileMock).toHaveBeenCalledWith( + "adb", + ["-s", androidSerial, "install", "-r", "/abs/app.apk"], + expect.any(Object) + ); + // Specifically no xcrun — iOS tooling would fail fast on a non-UUID udid. + expect(execFileMock).not.toHaveBeenCalledWith("xcrun", expect.anything(), expect.anything()); + }); + + it("appends `-g` when grantPermissions is set (runtime perms auto-granted)", async () => { + execFileMock.mockReturnValue({ stdout: "Success\n", stderr: "" }); + await reinstallAppTool.execute!( + {}, + { + udid: androidSerial, + bundleId: "com.example.app", + appPath: "/abs/app.apk", + grantPermissions: true, + } + ); + expect(execFileMock.mock.calls[0]![1]).toEqual([ + "-s", + androidSerial, + "install", + "-r", + "-g", + "/abs/app.apk", + ]); + }); + + it("appends `-d` when allowDowngrade is set", async () => { + execFileMock.mockReturnValue({ stdout: "Success\n", stderr: "" }); + await reinstallAppTool.execute!( + {}, + { + udid: androidSerial, + bundleId: "com.example.app", + appPath: "/abs/app.apk", + allowDowngrade: true, + } + ); + expect(execFileMock.mock.calls[0]![1]).toEqual([ + "-s", + androidSerial, + "install", + "-r", + "-d", + "/abs/app.apk", + ]); + }); + + it("orders flags as -d then -g when both are set (matches adb's expected order)", async () => { + execFileMock.mockReturnValue({ stdout: "Success\n", stderr: "" }); + await reinstallAppTool.execute!( + {}, + { + udid: androidSerial, + bundleId: "com.example.app", + appPath: "/abs/app.apk", + grantPermissions: true, + allowDowngrade: true, + } + ); + const args = execFileMock.mock.calls[0]![1] as string[]; + const dIdx = args.indexOf("-d"); + const gIdx = args.indexOf("-g"); + expect(dIdx).toBeGreaterThan(-1); + expect(gIdx).toBeGreaterThan(-1); + expect(dIdx).toBeLessThan(gIdx); + }); + + it("throws when the install output does not contain `Success`", async () => { + execFileMock.mockReturnValue({ + stdout: "Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", + stderr: "", + }); + await expect( + reinstallAppTool.execute!( + {}, + { udid: androidSerial, bundleId: "com.example.app", appPath: "/abs/app.apk" } + ) + ).rejects.toThrow(/adb install failed/); + }); +}); diff --git a/packages/tool-server/test/restart-app-dispatch.test.ts b/packages/tool-server/test/restart-app-dispatch.test.ts new file mode 100644 index 00000000..ac9daff6 --- /dev/null +++ b/packages/tool-server/test/restart-app-dispatch.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { restartAppTool } from "../src/tools/simulator/restart-app"; + +const iosUdid = "11111111-2222-3333-4444-555555555555"; +const androidSerial = "emulator-5554"; +const iosNativeApi = { ensureEnvReady: vi.fn().mockResolvedValue(undefined) }; + +beforeEach(() => { + execFileMock.mockReset().mockReturnValue({ stdout: "", stderr: "" }); + iosNativeApi.ensureEnvReady.mockClear().mockResolvedValue(undefined); +}); + +describe("restart-app.services", () => { + it("requests nativeDevtools on iOS so the AX injection is ready pre-launch", () => { + expect(restartAppTool.services({ udid: iosUdid, bundleId: "com.foo" })).toEqual({ + nativeDevtools: `NativeDevtools:${iosUdid}`, + }); + }); + + it("requests no services on Android — NativeDevtools is iOS-only", () => { + expect(restartAppTool.services({ udid: androidSerial, bundleId: "com.foo" })).toEqual({}); + }); +}); + +describe("restart-app.execute — iOS (behaviour preserved)", () => { + it("terminates then launches via simctl, refreshing native-devtools between", async () => { + await restartAppTool.execute!( + { nativeDevtools: iosNativeApi }, + { udid: iosUdid, bundleId: "com.apple.Preferences" } + ); + + expect(iosNativeApi.ensureEnvReady).toHaveBeenCalledTimes(1); + expect(execFileMock).toHaveBeenCalledTimes(2); + expect(execFileMock.mock.calls[0]![1]).toEqual([ + "simctl", + "terminate", + iosUdid, + "com.apple.Preferences", + ]); + expect(execFileMock.mock.calls[1]![1]).toEqual([ + "simctl", + "launch", + iosUdid, + "com.apple.Preferences", + ]); + }); + + it("swallows a terminate failure — app may already be stopped, launch must still run", async () => { + let n = 0; + execFileMock.mockImplementation(() => { + n += 1; + if (n === 1) return new Error("App is not running"); + return { stdout: "", stderr: "" }; + }); + + const result = await restartAppTool.execute!( + { nativeDevtools: iosNativeApi }, + { udid: iosUdid, bundleId: "com.apple.Preferences" } + ); + expect(result).toEqual({ restarted: true, bundleId: "com.apple.Preferences" }); + expect(execFileMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("restart-app.execute — Android", () => { + it("force-stops then monkey-launches — no xcrun calls", async () => { + await restartAppTool.execute!({}, { udid: androidSerial, bundleId: "com.android.settings" }); + expect(execFileMock).toHaveBeenCalledTimes(2); + expect(execFileMock.mock.calls[0]![1]).toEqual([ + "-s", + androidSerial, + "shell", + "am force-stop com.android.settings", + ]); + expect(execFileMock.mock.calls[1]![1]).toEqual([ + "-s", + androidSerial, + "shell", + "monkey -p com.android.settings -c android.intent.category.LAUNCHER 1", + ]); + expect(execFileMock).not.toHaveBeenCalledWith("xcrun", expect.anything(), expect.anything()); + }); + + it("throws when monkey cannot find an activity to relaunch", async () => { + let n = 0; + execFileMock.mockImplementation(() => { + n += 1; + if (n === 2) { + return { + stdout: "** No activities found to run, monkey aborted.", + stderr: "", + }; + } + return { stdout: "", stderr: "" }; + }); + + await expect( + restartAppTool.execute!({}, { udid: androidSerial, bundleId: "com.not.installed" }) + ).rejects.toThrow(/relaunch failed/); + }); +}); diff --git a/packages/tool-server/test/run-sequence-dispatch.test.ts b/packages/tool-server/test/run-sequence-dispatch.test.ts new file mode 100644 index 00000000..8b47f112 --- /dev/null +++ b/packages/tool-server/test/run-sequence-dispatch.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Registry, ToolDefinition } from "@argent/registry"; +import { createRunSequenceTool } from "../src/tools/interactions/run-sequence"; + +/** + * Stub registry that only implements what run-sequence reaches for: + * - invokeTool: delegates to a map of fake sub-tool handlers + * + * run-sequence changed its own `services` from `{ simulatorServer: ... }` to + * `{}` — the claim is that per-step `registry.invokeTool` handles service + * resolution for each sub-tool on its own. These tests pin that claim so a + * future regression (e.g. accidentally pre-resolving simulatorServer at + * run-sequence level) shows up in CI instead of hands-on. + */ +function stubRegistry( + handlers: Record) => Promise | unknown> +): Registry { + const invokeTool = vi.fn(async (id: string, args: unknown) => { + const handler = handlers[id]; + if (!handler) throw new Error(`no handler for ${id}`); + return handler(args as Record); + }); + return { invokeTool } as unknown as Registry; +} + +describe("run-sequence.services — no pre-warming", () => { + it("declares no services; sub-tool service resolution is delegated to invokeTool", () => { + // The previous version requested `{ simulatorServer: ... }` which + // pre-warmed the iOS server. With unified dispatch, each sub-tool resolves + // its own service. If a future change re-adds a service request here, the + // iOS-only `SimulatorServer` URN shape will leak onto Android runs and + // break them. + const tool = createRunSequenceTool(stubRegistry({})); + expect( + tool.services({ + udid: "emulator-5554", + steps: [{ tool: "gesture-tap", args: { x: 0.5, y: 0.5 } }], + }) + ).toEqual({}); + }); +}); + +describe("run-sequence.execute — step forwarding & udid injection", () => { + async function runOne( + udid: string, + toolName: string, + args: Record + ): Promise<{ calls: unknown[][]; result: unknown }> { + const calls: unknown[][] = []; + const registry = stubRegistry({ + [toolName]: async (a) => { + calls.push([toolName, a]); + return { ok: true }; + }, + }); + const tool = createRunSequenceTool(registry); + const result = await tool.execute!({}, { udid, steps: [{ tool: toolName, args, delayMs: 0 }] }); + return { calls, result }; + } + + it("auto-injects udid into each step's args and forwards to registry.invokeTool", async () => { + const { calls } = await runOne("11111111-2222-3333-4444-555555555555", "gesture-tap", { + x: 0.5, + y: 0.5, + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + "gesture-tap", + { x: 0.5, y: 0.5, udid: "11111111-2222-3333-4444-555555555555" }, + ]); + }); + + it("injects an Android udid identically — no platform branching at the sequence layer", async () => { + const { calls } = await runOne("emulator-5554", "gesture-swipe", { + fromX: 0.2, + fromY: 0.5, + toX: 0.8, + toY: 0.5, + }); + expect(calls[0]![1]).toMatchObject({ udid: "emulator-5554" }); + }); + + it("lets the sub-tool overwrite an explicit udid in args if provided (udid wins from top-level)", async () => { + // `{ ...step.args, udid }` places udid last, so it always overrides a stray + // udid in args. Without this, a user mistake in the args object could + // route a step to a different device. + const iosUdid = "11111111-2222-3333-4444-555555555555"; + const { calls } = await runOne(iosUdid, "gesture-tap", { + udid: "emulator-5554", // wrong — should be overridden + x: 0.5, + y: 0.5, + }); + expect((calls[0]![1] as { udid: string }).udid).toBe(iosUdid); + }); +}); + +describe("run-sequence.execute — error propagation", () => { + it("stops on the first thrown error and reports partial progress", async () => { + const calls: string[] = []; + const registry = stubRegistry({ + "gesture-tap": async () => { + calls.push("tap"); + return { ok: true }; + }, + "gesture-swipe": async () => { + calls.push("swipe"); + throw new Error("device offline"); + }, + "button": async () => { + calls.push("button"); + return { ok: true }; + }, + }); + const tool = createRunSequenceTool(registry); + const result = (await tool.execute!( + {}, + { + udid: "emulator-5554", + steps: [ + { tool: "gesture-tap", args: { x: 0.1, y: 0.1 }, delayMs: 0 }, + { + tool: "gesture-swipe", + args: { fromX: 0.5, fromY: 0.5, toX: 0.5, toY: 0.2 }, + delayMs: 0, + }, + { tool: "button", args: { button: "home" }, delayMs: 0 }, // must NOT execute + ], + } + )) as { + completed: number; + total: number; + steps: Array<{ tool: string; error?: string; result?: unknown }>; + }; + + expect(calls).toEqual(["tap", "swipe"]); // button skipped + expect(result.completed).toBe(1); + expect(result.total).toBe(3); + expect(result.steps).toHaveLength(2); + expect(result.steps[0]).toMatchObject({ tool: "gesture-tap", result: { ok: true } }); + expect(result.steps[1]).toMatchObject({ tool: "gesture-swipe", error: "device offline" }); + }); + + it("rejects a tool name outside the allow-list without invoking it", async () => { + const invoke = vi.fn(); + const tool = createRunSequenceTool({ invokeTool: invoke } as unknown as Registry); + const result = (await tool.execute!( + {}, + { + udid: "emulator-5554", + steps: [{ tool: "reinstall-app", args: { appPath: "/x" } }], + } + )) as { steps: Array<{ error?: string }>; completed: number }; + + expect(invoke).not.toHaveBeenCalled(); + expect(result.completed).toBe(0); + expect(result.steps[0]!.error).toMatch(/not allowed in run-sequence/); + }); +}); diff --git a/packages/tool-server/test/simulator-server-blueprint.test.ts b/packages/tool-server/test/simulator-server-blueprint.test.ts new file mode 100644 index 00000000..01a1ebac --- /dev/null +++ b/packages/tool-server/test/simulator-server-blueprint.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; + +// ─── Mocks ─────────────────────────────────────────────────────────── +// +// We mock at the module-boundary layer so the real blueprint factory runs — +// this is a repro of the dispatch, stdio and AX-automation behaviour, not a +// shape check. If any of these are quietly regressed, hands-on Android +// sessions will start failing before this test does, so the assertions below +// are deliberately specific (argv, stdio, ensureAutomationEnabled call count). + +const spawnMock = vi.fn(); +const ensureAutomationEnabledMock = vi.fn(); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, spawn: spawnMock }; +}); + +vi.mock("../src/blueprints/ax-service", () => ({ + ensureAutomationEnabled: ensureAutomationEnabledMock, +})); + +vi.mock("@argent/native-devtools-ios", () => ({ + simulatorServerBinaryPath: () => "/fake/bin/simulator-server", + simulatorServerBinaryDir: () => "/fake/bin", +})); + +function makeFakeProc() { + const proc = new EventEmitter() as EventEmitter & { + stdout: Readable; + stderr: Readable; + stdin: { write: ReturnType }; + kill: ReturnType; + }; + proc.stdout = new Readable({ read() {} }); + proc.stderr = new Readable({ read() {} }); + proc.stdin = { write: vi.fn() }; + proc.kill = vi.fn(); + return proc; +} + +/** + * Push an `api_ready` line into stdout so readline's line event fires and the + * blueprint resolves. We push on nextTick so the blueprint has time to attach + * its listener after calling `spawn`. + */ +function signalReady(proc: ReturnType, port: number) { + setImmediate(() => { + proc.stdout.push(`api_ready http://127.0.0.1:${port}\n`); + }); +} + +describe("simulatorServerBlueprint.factory — dispatch on udid shape", () => { + beforeEach(() => { + spawnMock.mockReset(); + ensureAutomationEnabledMock.mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("spawns the `ios` subcommand and warms the AX automation flag for a UUID udid", async () => { + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + + // Late import — the mocks are active at module-load time. + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const udid = "11111111-2222-3333-4444-555555555555"; + const factoryPromise = simulatorServerBlueprint.factory({}, udid); + signalReady(fakeProc, 55555); + const instance = await factoryPromise; + + // Contract under test: + expect(spawnMock).toHaveBeenCalledTimes(1); + const [binary, args, opts] = spawnMock.mock.calls[0]!; + expect(binary).toBe("/fake/bin/simulator-server"); + expect(args).toEqual(["ios", "--id", udid]); + // stdin must stay open — the server treats EOF on stdin as a shutdown signal. + // We verified this hands-on; if this regresses the server silently exits + // as soon as the tool-server pipes /dev/null. + expect(opts?.stdio).toEqual(["pipe", "pipe", "pipe"]); + + expect(ensureAutomationEnabledMock).toHaveBeenCalledTimes(1); + expect(ensureAutomationEnabledMock).toHaveBeenCalledWith(udid); + + expect(instance.api.apiUrl).toBe("http://127.0.0.1:55555"); + expect(typeof instance.api.pressKey).toBe("function"); + + await instance.dispose(); + expect(fakeProc.kill).toHaveBeenCalledTimes(1); + }); + + it("spawns the `android` subcommand and skips the iOS AX automation flag for an adb serial", async () => { + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const serial = "emulator-5554"; + const factoryPromise = simulatorServerBlueprint.factory({}, serial); + signalReady(fakeProc, 55556); + await factoryPromise; + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0]![1]).toEqual(["android", "--id", serial]); + + // No xcrun AX flag on Android — it is iOS-only and would error out. + expect(ensureAutomationEnabledMock).not.toHaveBeenCalled(); + }); + + it("also dispatches to `android` for the iOS-17 short UUID form? — no, it stays on `ios`", async () => { + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + // iOS 17+ physical-device short form (8-16 hex). + const udid = "00008030-001C25120C22802E"; + const factoryPromise = simulatorServerBlueprint.factory({}, udid); + signalReady(fakeProc, 55557); + await factoryPromise; + + expect(spawnMock.mock.calls[0]![1]).toEqual(["ios", "--id", udid]); + expect(ensureAutomationEnabledMock).toHaveBeenCalledWith(udid); + }); + + it("pressKey writes the shared stdin command protocol regardless of platform", async () => { + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const factoryPromise = simulatorServerBlueprint.factory({}, "emulator-5554"); + signalReady(fakeProc, 55558); + const instance = await factoryPromise; + + instance.api.pressKey("Down", 0x29); + instance.api.pressKey("Up", 0x29); + + expect(fakeProc.stdin.write).toHaveBeenNthCalledWith(1, "key Down 41\n"); + expect(fakeProc.stdin.write).toHaveBeenNthCalledWith(2, "key Up 41\n"); + }); + + it("swallows an iOS AX-automation failure — the server must still start", async () => { + // ensureAutomationEnabled is best-effort: if xcrun isn't on PATH, or the + // simulator is pre-booted with the flag set already, we must continue. + ensureAutomationEnabledMock.mockRejectedValueOnce(new Error("xcrun missing")); + + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const factoryPromise = simulatorServerBlueprint.factory( + {}, + "22222222-3333-4444-5555-666666666666" + ); + signalReady(fakeProc, 55559); + const instance = await factoryPromise; + + expect(instance.api.apiUrl).toBe("http://127.0.0.1:55559"); + }); +}); diff --git a/packages/tool-server/test/workspace-reader.test.ts b/packages/tool-server/test/workspace-reader.test.ts index f6f56480..f8c92d61 100644 --- a/packages/tool-server/test/workspace-reader.test.ts +++ b/packages/tool-server/test/workspace-reader.test.ts @@ -204,6 +204,8 @@ module.exports = getDefaultConfig(__dirname);` expect(snap.has_android_dir).toBe(false); expect(snap.ios_workspace).toBeNull(); expect(snap.has_podfile).toBe(false); + expect(snap.android_application_id).toBeNull(); + expect(snap.android_has_gradle).toBe(false); expect(snap.lockfile).toBeNull(); expect(snap.env_files).toEqual([]); expect(snap.scripts_dir_entries).toBeNull(); @@ -214,6 +216,46 @@ module.exports = getDefaultConfig(__dirname);` expect(snap.config_files_found).toEqual([]); }); + it("parses Android applicationId and gradle wrapper from android/app/build.gradle", async () => { + await writeJson(tempDir, "package.json", { name: "AndroidApp" }); + await mkdirIn(tempDir, "android"); + await writeFile(join(tempDir, "android", "gradlew"), "#!/usr/bin/env sh\n"); + await mkdirIn(tempDir, "android/app"); + await writeFile( + join(tempDir, "android", "app", "build.gradle"), + `android { + defaultConfig { + applicationId "com.example.androidapp" + versionCode 1 + } +}` + ); + + const snap = await readWorkspaceSnapshot(tempDir); + expect(snap.has_android_dir).toBe(true); + expect(snap.android_has_gradle).toBe(true); + expect(snap.android_application_id).toBe("com.example.androidapp"); + }); + + it("parses Android applicationId from Kotlin DSL (build.gradle.kts)", async () => { + await writeJson(tempDir, "package.json", { name: "AndroidKtsApp" }); + await mkdirIn(tempDir, "android/app"); + await writeFile( + join(tempDir, "android", "app", "build.gradle.kts"), + `android { + defaultConfig { + applicationId = "com.example.ktsapp" + } +}` + ); + + const snap = await readWorkspaceSnapshot(tempDir); + expect(snap.android_application_id).toBe("com.example.ktsapp"); + // No gradlew written, so has_gradle must be false — protects against a + // silent regression where either file's absence defaults to true. + expect(snap.android_has_gradle).toBe(false); + }); + it("extracts metro port from config", async () => { await writeText(tempDir, "metro.config.js", `module.exports = { server: { port: 9090 } };`); From 20933434628e4b990f356e9f6fcca1e9147683ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 12:43:39 +0200 Subject: [PATCH 002/149] feat(mcp): expose Android emulator control in MCP server + README - MCP instructions now describe the unified tool surface (iOS + Android dispatch on udid shape) and list platform-specific extras. - Package descriptions updated for both platforms. - README prerequisites split by platform (Xcode for iOS, Android SDK platform tools + emulator package for Android). - Adds unified-surface assertions to auto-screenshot test so any regression in the allow-list shows up immediately. --- README.md | 7 ++--- packages/mcp/package.json | 2 +- packages/mcp/src/mcp-server.ts | 8 +++--- packages/mcp/test/auto-screenshot.test.ts | 32 +++++++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c63486a8..9a62f2f1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@


-**[Argent](https://argent.swmansion.com)** is an **agentic toolkit** that gives your AI assistant direct access to iOS Simulators. Ask it to tap a button, run a profiler or reproduce an issue manually - all from within your CLI, without switching context. +**[Argent](https://argent.swmansion.com)** is an **agentic toolkit** that gives your AI assistant direct access to iOS Simulators and Android Emulators. Ask it to tap a button, run a profiler or reproduce an issue manually - all from within your CLI, without switching context. ```bash npx @swmansion/argent init @@ -18,7 +18,7 @@ npx @swmansion/argent init ## Capabilities -- **Autonomous iOS development** - Allow your agent to work with iOS apps on its own - let it build, open, interact with the app and debug it. Ask for reproducing issues, testing features manually, profiling your app and much more, without ever interrupting your work. +- **Autonomous iOS and Android development** - Allow your agent to work with iOS and Android apps on its own - let it build, open, interact with the app and debug it. Ask for reproducing issues, testing features manually, profiling your app and much more, without ever interrupting your work. - **UI interaction** - Give your agent full control toolkit - tapping, swiping, pinching, typing, gestures, hardware buttons and all other gears included. Let it navigate your app exactly as a user would, without lifting a finger. - **Profiling with batteries included** - Argent can perform and analyze both React-Native and Xcode Instruments profiling sessions. Get comprehensive summaries and ask to optimise your app where you find fit. - **Debugging and diagnostics** - Let your agent inspect logs, capture crash reports, and reproduce failing states on the simulator, so you can jump straight to the fix. @@ -37,8 +37,9 @@ npx @swmansion/argent init #### Prerequisites -- macOS with **Xcode** installed - **Node.js 18** or later +- For iOS: macOS with **Xcode** installed +- For Android: **Android SDK Platform Tools** (`adb`) on `PATH`, and the **Android Emulator** package if you want to boot AVDs from Argent. Create AVDs via Android Studio or `avdmanager`. #### Run `init` in your project diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8c81f8b6..8d98c1b5 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@swmansion/argent", "version": "0.5.2", - "description": "MCP server for iOS Simulator control", + "description": "MCP server for iOS Simulator and Android Emulator control", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/packages/mcp/src/mcp-server.ts b/packages/mcp/src/mcp-server.ts index 648f7e6f..90a2d4a9 100644 --- a/packages/mcp/src/mcp-server.ts +++ b/packages/mcp/src/mcp-server.ts @@ -122,9 +122,11 @@ export async function startMcpServer(): Promise { { capabilities: { tools: {} }, instructions: - "Argent — iOS Simulator Control for interacting, testing, profiling and debugging mobile applications. " + - "Always use discovery tools (describe / debugger-component-tree / screenshot) before tapping — never guess coordinates. " + - "On session end: call stop-all-simulator-servers and perform any necessary cleanup. " + + "Argent — iOS Simulator + Android Emulator control for interacting, testing, profiling and debugging mobile apps. " + + "Interaction tools (`gesture-tap`, `gesture-swipe`, `button`, `keyboard`, `rotate`, `screenshot`, `describe`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `run-sequence`) accept a `udid` and auto-dispatch iOS vs Android based on the id's shape (UUID → iOS, anything else → Android adb serial). " + + "Android-specific extras: `android-list-emulators`, `android-boot-emulator`, `android-stop-app`, `android-logcat`. iOS-specific: `list-simulators`, `boot-simulator`, `stop-simulator-server`, `stop-all-simulator-servers`, native-devtools suite, iOS Instruments profiler. " + + "Always use `describe` / `debugger-component-tree` / `screenshot` before tapping — never guess coordinates. " + + "On session end: call `stop-all-simulator-servers` for iOS and any necessary Android cleanup. " + "Full guidance is in the argent rule loaded from .claude/rules/argent.md.", } ); diff --git a/packages/mcp/test/auto-screenshot.test.ts b/packages/mcp/test/auto-screenshot.test.ts index 8f4c6111..3ec14f9f 100644 --- a/packages/mcp/test/auto-screenshot.test.ts +++ b/packages/mcp/test/auto-screenshot.test.ts @@ -195,3 +195,35 @@ describe("AUTO_SCREENSHOT_TOOLS and delay map consistency", () => { } }); }); + +// --------------------------------------------------------------------------- +// shouldAutoScreenshot — unified tools trigger one screenshot regardless of platform +// --------------------------------------------------------------------------- +describe("shouldAutoScreenshot — unified surface", () => { + it("returns false for the screenshot tool itself (prevents recursion)", () => { + expect(shouldAutoScreenshot("screenshot")).toBe(false); + expect(shouldAutoScreenshot("mcp__argent__screenshot")).toBe(false); + }); + + it("returns true for unified interaction tools", () => { + for (const t of [ + "gesture-tap", + "gesture-swipe", + "button", + "keyboard", + "rotate", + "launch-app", + "restart-app", + "open-url", + "describe", + "run-sequence", + ]) { + expect(shouldAutoScreenshot(t)).toBe(true); + } + }); + + it("normalizes MCP-prefixed names before looking up the allow-list", () => { + expect(shouldAutoScreenshot("mcp__argent__gesture-tap")).toBe(true); + expect(shouldAutoScreenshot("mcp__argent__launch-app")).toBe(true); + }); +}); From 6e0d64fc4db8ed3e041e65025638052d63ff290c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 12:43:50 +0200 Subject: [PATCH 003/149] docs(skills): Android emulator skills + platform-aware argent rule - Adds `argent-android-emulator-setup` and `argent-android-emulator-interact` SKILLs mirroring their iOS counterparts. The interact skill documents the unified tool surface and Android-specific gotchas (Metro reachability via `adb reverse`, first-launch permission prompts, locked screen / DRM). - `argent.md` rule gains a `` section explaining how the udid shape selects iOS vs Android internally, plus updated skill routing that points to the right platform-specific skill. - `argent-simulator-interact`, `argent-test-ui-flow`, `argent-react-native-app-workflow`, and `argent-metro-debugger` now cover both platforms (RN Metro reachability, gradle, logcat). - `argent-environment-inspector` reports Android applicationId and gradle wrapper presence so downstream workflow skills can drive `./gradlew` builds without extra inspection. --- .../agents/argent-environment-inspector.md | 2 + packages/skills/package.json | 2 +- packages/skills/rules/argent.md | 85 +++++++++++++------ .../argent-android-emulator-interact/SKILL.md | 58 +++++++++++++ .../argent-android-emulator-setup/SKILL.md | 29 +++++++ .../skills/argent-metro-debugger/SKILL.md | 14 ++- .../argent-react-native-app-workflow/SKILL.md | 32 ++++++- .../skills/argent-simulator-interact/SKILL.md | 12 ++- .../skills/argent-test-ui-flow/SKILL.md | 39 ++++++--- 9 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 packages/skills/skills/argent-android-emulator-interact/SKILL.md create mode 100644 packages/skills/skills/argent-android-emulator-setup/SKILL.md diff --git a/packages/skills/agents/argent-environment-inspector.md b/packages/skills/agents/argent-environment-inspector.md index 8f311551..b5abbe1e 100644 --- a/packages/skills/agents/argent-environment-inspector.md +++ b/packages/skills/agents/argent-environment-inspector.md @@ -89,6 +89,8 @@ Return a JSON object with these top-level fields: | `startup_commands` | array | `[{ command, context }]` — concrete dev server start commands | | `build_commands` | array | `[{ command, platform, context }]` — build commands per platform | | `argent_workflow` | object | `{ start_dev_server, build_ios, build_android, notes }` — exact commands for Argent | +| `android_application_id` | string\|null | Android `applicationId` parsed from `android/app/build.gradle` or `build.gradle.kts` | +| `android_has_gradle` | bool | True when `android/gradlew` exists; implies the Android build is invokable via `./gradlew` | | `configs` | object | Paths to metro, babel, app, tsconfig, pubspec, xcode, gradle configs (`null` if absent) | | `metro_port` | number\|null | From config or default 8081; `null` for non-RN | | `env_resolution` | object | `{ env_files, strategy, notes }` | diff --git a/packages/skills/package.json b/packages/skills/package.json index a5ecbfe2..7fc47ca2 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -3,7 +3,7 @@ "name": "@argent/skills", "version": "0.5.2", "type": "module", - "description": "Claude Code skills for iOS simulator interaction via argent", + "description": "Claude Code skills for iOS simulator and Android emulator interaction via argent", "scripts": { "install-skills": "node scripts/install.js" }, diff --git a/packages/skills/rules/argent.md b/packages/skills/rules/argent.md index a085ba50..448755d0 100644 --- a/packages/skills/rules/argent.md +++ b/packages/skills/rules/argent.md @@ -1,39 +1,64 @@ --- -description: Argent iOS Simulator Agent — always-on guidance for methodology and tools for working with, interacting, testing and profiling mobile app work +description: Argent Mobile App Agent — always-on guidance for methodology and tools for working with, interacting, testing and profiling iOS simulator and Android emulator apps alwaysApply: true --- -Argent MCP tools are available in this project for iOS simulator control. Argent MCP tools are the preferred form of interaction with the application. +Argent MCP tools are available in this project for iOS simulator and Android emulator control. Argent MCP tools are the preferred form of interaction with the application. Running MCP server and managing the Argent toolkit utilises `argent` command - if asked use `argent --help` for reference. To check current version of MCP server run `argent --version` command. Use cases: -- User mentions iOS simulator, device, or app interaction -- The app user is working with is a mobile application which can be run in the simulator +- User mentions iOS simulator, Android emulator, device, or app interaction +- The app user is working with is a mobile application which can be run in a simulator/emulator - Any tapping, swiping, typing, screenshotting, or inspecting a running app -- Running, debugging, or testing a React Native app -- Profiling performance or diagnosing re-renders in a React Native app +- Running, debugging, or testing a React Native app (iOS or Android) +- Profiling performance or diagnosing re-renders in a React Native app (iOS profiler tooling is iOS-only; React profiler works on either platform) + +Interaction tools are unified across iOS and Android. Pass the device id as `udid` and the tool-server dispatches based on its shape. + +- **iOS udid**: UUID shape — `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (from `list-simulators`). Or iOS 17+ short form `XXXXXXXX-XXXXXXXXXXXXXXXX`. +- **Android udid**: adb serial (from `android-list-emulators`) — `emulator-5554`, `R5CT12345678`, `192.168.1.7:5555`, etc. + +Unified tools (pass `udid`): `gesture-tap`, `gesture-swipe`, `gesture-custom`, `gesture-pinch`, `gesture-rotate`, `button`, `keyboard`, `rotate`, `screenshot`, `describe`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `run-sequence`. + +Navigation + gestures (including multi-touch pinch/rotate/custom) route through `simulator-server`, which the binary dispatches to iOS or Android internally. `describe` uses AXRuntime → native-devtools fallback on iOS and `uiautomator dump` on Android; app-lifecycle tools (`launch-app` / `restart-app` / `reinstall-app` / `open-url`) use `xcrun simctl` on iOS and `adb` / `am` / `monkey` on Android. + +Platform-specific tools (no unified counterpart): + +- **iOS**: `list-simulators`, `boot-simulator`, `stop-simulator-server`, `stop-all-simulator-servers`, native-devtools suite, iOS Instruments profiler, `paste`. +- **Android**: `android-list-emulators`, `android-boot-emulator`, `android-stop-app`, `android-logcat`. + +If the project only has an `android/` directory (no `ios/`), start from `android-list-emulators`; if only iOS, start from `list-simulators`. For hybrid projects, ask the user which platform to target. Never pass an iOS UDID to an Android-only tool or vice versa. + + **Never** derive tap coordinates from a screenshot Before **every** tap, you MUST call a discovery tool and extract coordinates from the result. This is not optional. Preferred tools are, in order: -- `describe` - native app-level components and safely targetable foreground apps. +**iOS:** + +- `describe` - native app-level components and safely targetable foreground apps - `native-describe-screen` - accessibility screen description via injected native devtools - `debugger-component-tree` - react-native specific components `native-user-interactable-view-at-point` / `native-view-at-point` are follow-up diagnostics once you already have a candidate point. -Whenever something changed YOU MUST first call `describe`, or another appropriate discovery tool so you do not hallucinate element positions. Do not guess coordinates if you can use discovery tool. Do not tap if you have not called a discovery tool in the current step. Screenshots alone are never sufficient for coordinates. +**Android:** + +- `android-describe-screen` - uiautomator-based UI tree (same shape as iOS `describe`) +- `debugger-component-tree` - react-native specific components (requires `adb reverse tcp:8081 tcp:8081` so Metro is reachable) + +Whenever something changed YOU MUST first call the platform's describe tool, or another appropriate discovery tool so you do not hallucinate element positions. Do not guess coordinates if you can use a discovery tool. Do not tap if you have not called a discovery tool in the current step. Screenshots alone are never sufficient for coordinates. If a **tap fails twice** at the same coordinates, **stop retrying**. Re-run the discovery tool. -If `describe` fails, **read the exact error before reacting**, follow the recovery guidance in `argent-simulator-interact` to choose the correct next action. +If the describe tool fails, **read the exact error before reacting**, follow the recovery guidance in `argent-simulator-interact` (iOS) or `argent-android-emulator-interact` (Android). -Before starting to interact with the app, read the `argent-simulator-interact` skill first. +Before starting to interact with the app, read `argent-simulator-interact` (iOS) or `argent-android-emulator-interact` (Android). @@ -42,17 +67,17 @@ Before starting to interact with the app, read the `argent-simulator-interact` s -- All simulator interactions go through argent MCP tools — never use `xcrun simctl`, - raw `curl` to simulator ports, or the simulator-server binary directly. +- All simulator/emulator interactions go through argent MCP tools — never use `xcrun simctl`, raw `adb` for tap/swipe/screenshot, `curl` to simulator ports, or the simulator-server binary directly. - Before calling any gesture tool for the first time, use ToolSearch to load its schema. -- Interaction tools (`gesture-tap`, `gesture-swipe`, `gesture-pinch`, `gesture-rotate`, `gesture-custom`, `launch-app`, etc.) return a screenshot automatically. - Call `screenshot` separately only for a baseline before any action or after a delay. -- Always open apps with `launch-app` or `open-url` — never tap home screen icons. -- Always use `run-sequence` when performing multiple sequential simulator actions where you don't need to observe the screen between steps. More in `simulator-interact` skill. -- When the session ends or the user says they are done: call `stop-all-simulator-servers`. - If the user started Metro separately, ask whether to call `stop-metro` (specify the port if not 8081). -- If tools provided by mcp-server are not sufficient and action can be done using `xcrun` or other commands, use the command. Examples: changing simulator options, performing simulator action such as lock, shake, etc. -- When waiting for an action, do not call `screenshot` repeatedly without a proper wait mechanism. For example, six consecutive `screenshot` calls with no adequate delay between them will cause context bloat. +- Interaction tools (`gesture-tap`, `gesture-swipe`, `button`, `keyboard`, `rotate`, `launch-app`, `restart-app`, `open-url`, `describe`, `run-sequence`) return a screenshot automatically. Call `screenshot` separately only for a baseline before any action or after a delay. +- Always open apps with `launch-app` / `open-url` — never tap home-screen / launcher icons. +- Use `run-sequence` when performing multiple sequential actions where you don't need to observe the screen between steps. Works on both iOS and Android; iOS-only step types (gesture-pinch / gesture-rotate / gesture-custom) throw if the run-sequence udid is Android. +- When the session ends or the user says they are done: + - iOS — call `stop-all-simulator-servers`. + - Android — shut down the emulator from its own UI or via `adb -s emu kill` if the user wants it off. Argent does not keep persistent per-emulator state, so no server-side teardown is required. + - If the user started Metro separately, ask whether to call `stop-metro` (specify the port if not 8081). +- If tools provided by mcp-server are not sufficient and an action can be done using `xcrun` / raw `adb` / other commands, use the command. Examples: simulator lock/shake, `adb emu rotate`, `adb reverse tcp:8081 tcp:8081` for Android Metro reachability. +- When waiting for an action, do not call `screenshot` repeatedly without a proper wait mechanism. Six consecutive screenshot calls with no adequate delay between them will cause context bloat. @@ -62,24 +87,32 @@ source — do not re-inspect files manually. If the subagent has not run yet and project type is unknown, run it first before proceeding. Always use subagents if available to run `gather-workspace-data` data tool, if possible do not run yourself. -When `is_react_native` is true: load `argent-react-native-app-workflow` skill. Use `debugger-component-tree` for element discovery - if the responses are large or unhelpful, try `describe`. +When `is_react_native` is true: load `argent-react-native-app-workflow` skill. Use `debugger-component-tree` for element discovery — if the responses are large or unhelpful, fall back to `describe` (iOS) or `android-describe-screen` (Android). Load the matching skill before starting work and executing tools from argent-mcp — skills contain the full step-by-step procedure and edge-case handling for each workflow. -SIMULATOR SETUP +iOS SIMULATOR SETUP Skill: `argent-simulator-setup` -When: Beginning a task that involves the simulator, no simulator booted yet, need UDID or simulator-server. +When: Beginning a task that involves the iOS simulator, no simulator booted yet, need UDID or simulator-server. + +ANDROID EMULATOR SETUP +Skill: `argent-android-emulator-setup` +When: Beginning a task that involves the Android emulator, no emulator running yet, need a serial, or about to install an APK. -TAPPING, SWIPING, TYPING, GESTURES, SCREENSHOTS, SCROLLING +iOS TAPPING, SWIPING, TYPING, GESTURES, SCREENSHOTS, SCROLLING Skill: `argent-simulator-interact` -When: Performing touch interactions, typing, pressing hardware buttons, launching/restarting apps, opening URLs, rotating device, or taking standalone screenshots. +When: Performing touch interactions on iOS, typing, pressing hardware buttons, launching/restarting apps, opening URLs, rotating device, or taking standalone screenshots. + +ANDROID TAPPING, SWIPING, TYPING, GESTURES, SCREENSHOTS, SCROLLING +Skill: `argent-android-emulator-interact` +When: Performing touch interactions on Android, typing, pressing hardware buttons, launching/restarting apps, opening URLs, rotating device, reading logcat, or taking standalone screenshots. RUNNING / BUILDING / DEBUGGING REACT NATIVE APP Skill: `argent-react-native-app-workflow` -When: Project is react-native, starting Metro or running iOS app, build failures, pod issues, lost Metro connection, reading logs, reloading JS bundle, reinstalling app. +When: Project is react-native, starting Metro or running the iOS / Android app, build failures, pod issues, lost Metro connection, reading logs, reloading JS bundle, reinstalling app. Includes `./gradlew` and `adb reverse` guidance for the Android path. JS EVALUATION, METRO CONNECTION, REACT NATIVE Skill: `argent-metro-debugger` diff --git a/packages/skills/skills/argent-android-emulator-interact/SKILL.md b/packages/skills/skills/argent-android-emulator-interact/SKILL.md new file mode 100644 index 00000000..f0107d1f --- /dev/null +++ b/packages/skills/skills/argent-android-emulator-interact/SKILL.md @@ -0,0 +1,58 @@ +--- +name: argent-android-emulator-interact +description: Android-specific notes for interacting with the UI. Use alongside `argent-simulator-interact` — the core interaction tools (tap/swipe/type/describe/...) are unified and auto-dispatch by device id. +--- + +## Unified tool surface + +The interaction tools are the same on iOS and Android. Pass the Android adb `serial` (e.g. `emulator-5554`) as `udid` and the tool auto-dispatches. + +Use these tools directly — no `android-*` prefix: + +| Tool | Works on | Notes | +| ---------------- | ------------- | -------------------------------------------------------------------------------------------------------------------- | +| `gesture-tap` | iOS + Android | Simulator-server WebSocket on both platforms | +| `gesture-swipe` | iOS + Android | | +| `gesture-custom` | iOS + Android | Multi-touch via simulator-server — long-press / drag / arbitrary sequences | +| `gesture-pinch` | iOS + Android | True two-finger pinch-to-zoom on both platforms | +| `gesture-rotate` | iOS + Android | Two-finger rotation. For device orientation use the `rotate` tool | +| `button` | iOS + Android | home, back, power, volumeUp, volumeDown, appSwitch, actionButton — the binary maps to each platform's native keycode | +| `keyboard` | iOS + Android | USB HID keycodes routed through simulator-server; binary maps internally | +| `rotate` | iOS + Android | | +| `screenshot` | iOS + Android | Simulator-server HTTP → `http://` URL on both platforms | +| `describe` | iOS + Android | iOS: AXRuntime → native-devtools fallback. Android: `uiautomator dump` | +| `launch-app` | iOS + Android | iOS: bundle id via simctl. Android: package name via `am start` / `monkey`. Optional `activity` on Android | +| `restart-app` | iOS + Android | Android: `am force-stop` + `monkey` relaunch | +| `reinstall-app` | iOS + Android | iOS: `.app`. Android: `.apk`. Android extras: `grantPermissions`, `allowDowngrade` | +| `open-url` | iOS + Android | Works for any scheme a registered app handles | +| `run-sequence` | iOS + Android | All gesture/button/keyboard/rotate tools allowed — works identically on both platforms | + +For tool-by-tool usage see `argent-simulator-interact`. + +## Android-only tools + +These have no iOS equivalent and keep their `android-` prefix: + +| Tool | Purpose | +| ------------------------ | ---------------------------------------------------------------------------------------- | +| `android-list-emulators` | List adb devices + available AVDs | +| `android-boot-emulator` | Boot an AVD by name (cold boot by default; 2–5 min; clean failure if it doesn't come up) | +| `android-stop-app` | `am force-stop` without relaunching | +| `android-logcat` | Recent log lines. Filter by `bundleId`, `priority` (V/D/I/W/E/F), `tag` | + +## Platform detection + +The tool-server looks at the `udid` string: + +- `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` → iOS simulator UDID +- `XXXXXXXX-XXXXXXXXXXXXXXXX` → iOS 17+ short form +- Anything else (e.g. `emulator-5554`, `R5CT12345678`) → Android adb serial + +Pass iOS UDIDs from `list-simulators` and Android serials from `android-list-emulators`. Do not pass them to the wrong platform — dispatch is automatic. + +## Android-specific gotchas + +- **Metro reachability**: `adb -s reverse tcp:8081 tcp:8081` before the RN app starts, or Metro won't be reachable from the device. Re-run if the device restarts. +- **First-launch permission prompts**: pass `grantPermissions: true` to `reinstall-app` on Android so the app skips the runtime-permission dialogs. +- **Locked screen / secure surfaces**: `describe` throws a clear error if `uiautomator dump` can't capture (keyguard, DRM, Play Integrity). Unlock the device or fall back to `screenshot`. +- **APK vs .app in `reinstall-app`**: pass `.apk` absolute path on Android; `.app` directory on iOS. The tool dispatches based on `udid`. diff --git a/packages/skills/skills/argent-android-emulator-setup/SKILL.md b/packages/skills/skills/argent-android-emulator-setup/SKILL.md new file mode 100644 index 00000000..31bb10b0 --- /dev/null +++ b/packages/skills/skills/argent-android-emulator-setup/SKILL.md @@ -0,0 +1,29 @@ +--- +name: argent-android-emulator-setup +description: Set up and connect to an Android emulator using argent MCP tools. Use when starting a new session on Android, booting an emulator, getting a device serial, or before any UI interaction task. +--- + +## 1. Prerequisites + +- **Android SDK Platform Tools** on PATH — provides `adb`. +- **Android Emulator** on PATH — needed to boot AVDs via `android-boot-emulator`. If you will only use an already-running emulator or a physical device, adb alone is sufficient. +- An AVD created via Android Studio or `avdmanager create avd`. + +Verify with `adb version` and `emulator -list-avds`. + +## 2. Setup + +1. **Find a ready device** — call `android-list-emulators`. Ready devices have `state: "device"` and come first. Pick the first serial (e.g. `emulator-5554`) unless the user specified one. +2. **Boot if needed** — if nothing is ready, call `android-boot-emulator` with the AVD `name` from the same call's `avds` list. The tool cold-boots by default (reliability over speed — 2–5 min typical) and returns a clean `serial`. On any stage failure it kills the emulator process it started, so your next call begins from a clean state. +3. **Metro (for React Native)** — once a device is up, run `adb -s reverse tcp:8081 tcp:8081` so the device can reach Metro on your host. Repeat if the device restarts. See the `argent-metro-debugger` skill. + +## 3. Using the device + +Pass the Android serial as `udid` to the unified interaction tools — `tap`, `swipe`, `describe`, `screenshot`, `launch-app`, `keyboard`, etc. The tool-server auto-dispatches based on the id shape. See `argent-simulator-interact` (the base interaction skill, platform-neutral) and `argent-android-emulator-interact` (Android-specific gotchas). + +## 4. Notes + +- Serials are the adb device id. iOS UDIDs and Android serials are not interchangeable, but you do NOT need to tell the tools which platform — dispatch is automatic. +- Android does not have the iOS native-devtools dylib equivalent. `describe` uses `uiautomator` on Android, which is shallower than the iOS AX tree but covers most tap-target discovery. +- For first-launch permission prompts, pass `grantPermissions: true` to `reinstall-app`. +- To kill the emulator when you're done, run `adb -s emu kill` from a shell. diff --git a/packages/skills/skills/argent-metro-debugger/SKILL.md b/packages/skills/skills/argent-metro-debugger/SKILL.md index 11884309..d00c3e66 100644 --- a/packages/skills/skills/argent-metro-debugger/SKILL.md +++ b/packages/skills/skills/argent-metro-debugger/SKILL.md @@ -7,11 +7,21 @@ description: Debug a React Native app via Metro CDP using argent debugger tools. The debugger requires **Metro dev server running** (default `localhost:8081`) and **a React Native app connected to Metro** (at least one CDP target). Verify via `debugger-status`. +### Android: reverse port for Metro + +Android emulators and physical devices do not resolve the host's `localhost` by default. Before the RN app can reach Metro, forward port 8081 (or whichever port Metro is on) from the device back to the host: + +```bash +adb -s reverse tcp:8081 tcp:8081 +``` + +`` comes from `android-list-emulators`. Once reversed, the app on the device connects to Metro just like an iOS simulator does, and all `debugger-*` / `network-*` / `react-profiler-*` tools work unchanged. If the device restarts or adb drops, re-run the command. A failing Metro connection on Android almost always means `adb reverse` has not been done or has been lost. + ## 2. Tool Overview -All tools accept `port` (default 8081) AND `device_id` (the iOS Simulator UDID, a.k.a. `logicalDeviceId`). Always make sure you target the correct app on the correct device. +All tools accept `port` (default 8081) AND `device_id` (the iOS Simulator UDID or Android serial, a.k.a. `logicalDeviceId` — the CDP-reported id that matches the device). Always make sure you target the correct app on the correct device. -One Metro port can serve multiple connected devices (e.g. two simulators on `localhost:8081`). `device_id` pins every debugger/network/profiler call to a specific device so sessions do not collide. +One Metro port can serve multiple connected devices (e.g. two simulators on `localhost:8081`, or an iOS simulator alongside an Android emulator with `adb reverse` set up). `device_id` pins every debugger/network/profiler call to a specific device so sessions do not collide. ### Connect & diagnostics diff --git a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md index aa61f1d3..3e216cdc 100644 --- a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md +++ b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md @@ -1,6 +1,6 @@ --- name: argent-react-native-app-workflow -description: Step-by-step workflows for developing or debugging React Native apps with iOS simulator. Use when starting the app, debugging Metro, fixing builds, diagnosing runtime errors, or running tests. +description: Step-by-step workflows for developing or debugging React Native apps on iOS simulator or Android emulator. Use when starting the app, debugging Metro, fixing builds, diagnosing runtime errors, or running tests. --- ## 1. Starting the React Native App @@ -57,6 +57,36 @@ Optional: specify device or simulator, e.g. `npx react-native run-ios --simulato - [ ] Command run from project root - [ ] If simulator not booted: use the `boot-simulator` tool with proper UDID. Refer to the `argent-simulator-setup` skill. +### 1.4 Run the Android App + +In a **separate** terminal: + +**Use the project's custom script if one exists** (e.g. `npm run android`, `yarn android:debug`). Otherwise build and install via Gradle + the Android tools: + +```bash +# Build the debug APK from the android/ directory +cd android && ./gradlew :app:assembleDebug && cd .. + +# Resulting APK is typically at: +# android/app/build/outputs/apk/debug/app-debug.apk +``` + +Then, using the argent MCP tools (note: the interaction tools are unified — pass the Android serial as `udid`): + +1. `android-list-emulators` — pick a ready serial (or boot one via `android-boot-emulator`). See the `argent-android-emulator-setup` skill. +2. `reinstall-app` with `udid=`, `bundleId=`, absolute `appPath=`. Set `grantPermissions: true` to skip runtime permission prompts on first launch. +3. `launch-app` with `udid=` and `bundleId=` (from `android/app/build.gradle` — the environment inspector surfaces this as `android_application_id`). +4. **Metro reachability**: run `adb -s reverse tcp:8081 tcp:8081` so the app on the device can reach Metro on your host. Repeat if the device restarts or adb drops. See the `argent-metro-debugger` skill. + +Alternative one-shot: `npx react-native run-android` builds, installs, and launches in a single step. Use this when you don't need explicit control over the emulator serial. + +**Agent checklist:** + +- [ ] Metro is running +- [ ] `adb -s reverse tcp:8081 tcp:8081` done +- [ ] Command run from project root (or `./gradlew` from `android/`) +- [ ] If emulator not booted: `android-boot-emulator` first + --- ## 2. Ensuring / Debugging Metro diff --git a/packages/skills/skills/argent-simulator-interact/SKILL.md b/packages/skills/skills/argent-simulator-interact/SKILL.md index c11a0bf3..a0dd5229 100644 --- a/packages/skills/skills/argent-simulator-interact/SKILL.md +++ b/packages/skills/skills/argent-simulator-interact/SKILL.md @@ -1,13 +1,21 @@ --- name: argent-simulator-interact -description: Interact with an iOS simulator using argent MCP tools. Use when tapping UI elements, perfroming gestures, scrolling, typing text, pressing hardware buttons, launching apps, opening URLs, taking screenshots. +description: Interact with an iOS simulator or Android emulator using argent MCP tools. Use when tapping UI elements, performing gestures, scrolling, typing text, pressing hardware buttons, launching apps, opening URLs, taking screenshots. --- +## Unified tool surface + +All interaction tools below accept a `udid` parameter and auto-dispatch iOS vs Android based on its shape (UUID → iOS simulator, anything else → Android adb serial). You use the same tool names on both platforms. + +For Android-specific caveats (gestures that only exist on iOS, Android-only buttons, Metro `adb reverse`, locked-screen describe errors) see `argent-android-emulator-interact`. + ## 1. Before You Start If you delegate simulator tasks to sub-agents, make sure they have MCP permissions. -Use `list-simulators` to find available simulators. **Pick the first result** if specific not specified by user — booted iPhones are listed first. If none are booted, use `boot-simulator` first. +iOS: use `list-simulators`. **Pick the first result** if not specified by the user — booted iPhones are listed first. If none are booted, use `boot-simulator` first. + +Android: use `android-list-emulators`. Pick the first `state: "device"`. If none are booted, use `android-boot-emulator` first. See `argent-android-emulator-setup`. **Load tool schemas before first use.** Gesture tools (`gesture-tap`, `gesture-swipe`, `gesture-pinch`, `gesture-rotate`, `gesture-custom`) may be deferred — their parameter schemas are not loaded until fetched. Always use ToolSearch to load the schemas of all gesture tools you plan to use **before** calling any of them. If you skip this step, parameters may be coerced to strings instead of numbers, causing validation errors. diff --git a/packages/skills/skills/argent-test-ui-flow/SKILL.md b/packages/skills/skills/argent-test-ui-flow/SKILL.md index 94bc399d..0035cb74 100644 --- a/packages/skills/skills/argent-test-ui-flow/SKILL.md +++ b/packages/skills/skills/argent-test-ui-flow/SKILL.md @@ -1,19 +1,30 @@ --- name: argent-test-ui-flow -description: Autonomously test an iOS app UI by running interact-screenshot-verify loops using argent simulator tools. Use when testing a UI flow, verifying login works, testing navigation, or running an end-to-end UI test scenario. +description: Autonomously test an app UI (iOS or Android) by running interact-screenshot-verify loops using argent MCP tools. Use when testing a UI flow, verifying login works, testing navigation, or running an end-to-end UI test scenario. --- +## Platform-agnostic + +The interaction tool names are identical on iOS and Android — `gesture-tap`, `gesture-swipe`, `describe`, `screenshot`, `launch-app`, etc. — and the tool-server auto-dispatches based on the `udid` you pass (UUID-shape → iOS, adb serial → Android). + +Get a `udid` via: + +| Platform | Setup skill | Find devices with | +| -------- | ------------------------------- | ---------------------------------------------------------------- | +| iOS | `argent-simulator-setup` | `list-simulators` → `boot-simulator` if none booted | +| Android | `argent-android-emulator-setup` | `android-list-emulators` → `android-boot-emulator` if none ready | + ## 1. Workflow -All interactions go through argent MCP tools. Ensure the simulator is booted before starting. +All interactions go through argent MCP tools. Ensure the simulator/emulator is ready before starting. 1. **Baseline screenshot**: Call `screenshot` to see the current UI state. 2. **Find target**: Before tapping, use a discovery tool to get element coordinates: - - **React Native apps**: use `debugger-component-tree` — it returns component names with (tap: x,y) coordinates. This is the preferred tool for RN apps. To use it, resolve the `argent-react-native-app-workflow` skill for setup. - - **Standard iOS app screens and in-app modals**: use `describe` — it returns the accessibility element tree with normalized frame coordinates. - - **Permission prompts / system modal overlays**: still try `describe` first. Fall back to `screenshot` only if the overlay is not exposed reliably. + - **React Native apps**: use `debugger-component-tree` — it returns component names with (tap: x,y) coordinates. This is the preferred tool for RN apps on either platform. To use it, resolve the `argent-react-native-app-workflow` skill for setup; on Android you must also run `adb -s reverse tcp:8081 tcp:8081` so Metro is reachable from the device. + - **Standard app screens and in-app modals**: use `describe`. On iOS this returns the AX tree (falls back to native-devtools when AX is empty); on Android it returns the uiautomator tree in the same DescribeNode shape. + - **Permission prompts / system modal overlays**: try `describe` first. Fall back to `screenshot` only if the overlay is not exposed reliably. - **Fallback**: use `screenshot` to estimate where the desired component is, then verify immediately after the action. -3. **Interact**: Perform the action (`gesture-tap`, `gesture-swipe`, `paste`, etc.) — you receive a screenshot automatically. +3. **Interact**: Perform the action (`gesture-tap`, `gesture-swipe`, `keyboard`, `button`, ...) — you receive a screenshot automatically. 4. **Verify**: Check the returned screenshot for expected results. If it shows a loading/transitional state, retake with `screenshot`. 5. **Repeat** for each step in the flow. @@ -75,10 +86,12 @@ Steps: ## Related Skills -| Skill | When to use | -| ---------------------------------- | ------------------------------------------------ | -| `argent-simulator-interact` | Detailed tool usage for tapping, swiping, typing | -| `argent-simulator-setup` | Booting and connecting a simulator | -| `argent-react-native-app-workflow` | Starting the app, Metro, build issues | -| `argent-metro-debugger` | Breakpoints, console logs, JS evaluation | -| `argent-create-flow` | Record a test sequence as a replayable flow | +| Skill | When to use | +| ---------------------------------- | ---------------------------------------------------------- | +| `argent-simulator-interact` | Detailed tool usage for tapping, swiping, typing (iOS) | +| `argent-android-emulator-interact` | Detailed tool usage for tapping, swiping, typing (Android) | +| `argent-simulator-setup` | Booting and connecting an iOS simulator | +| `argent-android-emulator-setup` | Booting and connecting an Android emulator | +| `argent-react-native-app-workflow` | Starting the app, Metro, build issues | +| `argent-metro-debugger` | Breakpoints, console logs, JS evaluation | +| `argent-create-flow` | Record a test sequence as a replayable flow | From 7e21ef3ae8447c1b592ec2b91dd18dd22301b7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 13:36:23 +0200 Subject: [PATCH 004/149] refactor: unify list/boot lifecycle tools into list-devices + boot-device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `list-simulators` and `android-list-emulators` collapse into a single `list-devices` that returns iOS simulators and Android devices/emulators in one tagged array (each entry carries a `platform` discriminator), plus the available Android AVDs. Callers no longer have to know which platform to query first. `boot-simulator` and `android-boot-emulator` collapse into a single `boot-device`. Pass `udid` to boot an iOS simulator or `avdName` to launch an Android emulator — the tool picks the platform from which argument is provided and returns a tagged payload. The Android boot stages (AVD validate → spawn → adb register → wait-for-device → boot_completed → PackageManager sanity) are unchanged, including cold-boot default and cleanup-on-failure. Existing unified-surface tools (gesture-tap, describe, launch-app, etc.) continue to dispatch on the udid shape — no changes there. --- .../tools/android/android-boot-emulator.ts | 225 -------------- .../tools/android/android-list-emulators.ts | 30 -- .../src/tools/devices/boot-device.ts | 280 ++++++++++++++++++ .../src/tools/devices/list-devices.ts | 119 ++++++++ .../src/tools/simulator/boot-simulator.ts | 49 --- .../src/tools/simulator/list-simulators.ts | 64 ---- .../tool-server/src/utils/setup-registry.ts | 18 +- ...-simulator.test.ts => boot-device.test.ts} | 56 +++- .../tool-server/test/list-devices.test.ts | 172 +++++++++++ 9 files changed, 627 insertions(+), 386 deletions(-) delete mode 100644 packages/tool-server/src/tools/android/android-boot-emulator.ts delete mode 100644 packages/tool-server/src/tools/android/android-list-emulators.ts create mode 100644 packages/tool-server/src/tools/devices/boot-device.ts create mode 100644 packages/tool-server/src/tools/devices/list-devices.ts delete mode 100644 packages/tool-server/src/tools/simulator/boot-simulator.ts delete mode 100644 packages/tool-server/src/tools/simulator/list-simulators.ts rename packages/tool-server/test/{boot-simulator.test.ts => boot-device.test.ts} (56%) create mode 100644 packages/tool-server/test/list-devices.test.ts diff --git a/packages/tool-server/src/tools/android/android-boot-emulator.ts b/packages/tool-server/src/tools/android/android-boot-emulator.ts deleted file mode 100644 index 4b07bb2d..00000000 --- a/packages/tool-server/src/tools/android/android-boot-emulator.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { spawn } from "node:child_process"; -import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; -import { - adbShell, - emulatorBinaryName, - listAndroidDevices, - listAvds, - runAdb, - waitForBootCompleted, -} from "../../utils/adb"; - -const zodSchema = z.object({ - avdName: z - .string() - .describe("AVD name to boot (from `android-list-emulators`). Example: `Pixel_7_API_34`."), - coldBoot: z - .boolean() - .optional() - .describe( - "Skip the AVD snapshot and cold-boot. Defaults to true — cold boot is slower but avoids " + - "the common failure where a corrupt snapshot leaves the emulator stuck at `offline` for several minutes." - ), - noWindow: z - .boolean() - .optional() - .describe( - "Launch the emulator headless (no UI window). Useful for CI. Defaults to false — " + - "the UI surfaces boot progress, which helps when diagnosing slow cold boots." - ), - bootTimeoutMs: z - .number() - .int() - .min(30_000) - .max(900_000) - .optional() - .describe( - "Overall budget for the full boot sequence (adb-appearance + boot_completed). Defaults to 480000 (8 min). Clamped to [30s, 15min]." - ), -}); - -// Each stage has its own sub-budget so a hang in one stage cannot consume the -// entire overall budget and a bootTimeoutMs bump doesn't quietly mask a regression. -const STAGE_BUDGET = { - qemuVisible: 30_000, // time from spawn → qemu-system-* process alive - adbRegister: 60_000, // adb devices sees the serial for this AVD - deviceReady: 180_000, // adb -s wait-for-device returns (state === "device") - bootCompleted: 300_000, // sys.boot_completed = 1 -} as const; - -async function killEmulatorQuietly(serial: string | null): Promise { - if (serial) { - await runAdb(["-s", serial, "emu", "kill"], { timeoutMs: 5_000 }).catch(() => {}); - } -} - -async function findSerialByAvdName(avdName: string, deadline: number): Promise { - while (Date.now() < deadline) { - const devices = await listAndroidDevices().catch(() => []); - const match = devices.find((d) => d.isEmulator && d.avdName === avdName); - if (match) return match.serial; - await new Promise((r) => setTimeout(r, 1_500)); - } - return null; -} - -async function listNewEmulatorSerials(before: Set): Promise { - const { stdout } = await runAdb(["devices"]).catch(() => ({ stdout: "", stderr: "" })); - const lines = stdout.split("\n"); - const now: string[] = []; - for (const line of lines) { - const m = line.match(/^(emulator-\d+)\s+/); - if (m) now.push(m[1]!); - } - return now.filter((s) => !before.has(s)); -} - -export const androidBootEmulatorTool: ToolDefinition< - z.infer, - { booted: boolean; serial: string; avdName: string; coldBoot: boolean } -> = { - id: "android-boot-emulator", - description: - "Start an Android emulator by AVD name and wait until it finishes booting. " + - "Cold-boots by default (skips the snapshot) because corrupt snapshots are the #1 cause of silent boot hangs. " + - "Expect 2–5 minutes on Apple Silicon; 5–10 minutes on older machines or cold disks. " + - "Returns { booted, serial, avdName, coldBoot }. On any stage failure the tool kills the emulator process it started and returns a clear error, so the next call begins from a clean state.", - zodSchema, - services: () => ({}), - async execute(_services, params) { - const overallBudget = params.bootTimeoutMs ?? 480_000; - const overallDeadline = Date.now() + overallBudget; - // Default to TRUE — reliability over speed per user direction. Callers who - // need a warm boot for speed can opt in explicitly. - const coldBoot = params.coldBoot ?? true; - - // ── Stage 0: validate AVD exists ────────────────────────────────── - const avds = await listAvds(); - if (avds.length === 0) { - throw new Error( - "`emulator -list-avds` returned no AVDs. Either the Android Emulator package is not on PATH, " + - "or no AVDs are defined. Create one via Android Studio or `avdmanager create avd`." - ); - } - if (!avds.some((a) => a.name === params.avdName)) { - throw new Error( - `AVD "${params.avdName}" not found. Available: ${avds.map((a) => a.name).join(", ")}.` - ); - } - - // Snapshot the serials already known so we can identify the new one, as a - // fallback if the AVD-name lookup (via getprop) is slow to return. - const serialsBefore = new Set( - (await listAndroidDevices().catch(() => [])).map((d) => d.serial) - ); - - // ── Stage 1: spawn emulator ─────────────────────────────────────── - const emulatorArgs = ["-avd", params.avdName]; - if (coldBoot) emulatorArgs.push("-no-snapshot-load"); - if (params.noWindow) emulatorArgs.push("-no-window"); - // `-delay-adb` and `-read-only` would complicate the reliability story. - // Keep the arg set minimal so failure modes are easy to reason about. - - const child = spawn(emulatorBinaryName(), emulatorArgs, { - detached: true, - stdio: "ignore", - }); - child.unref(); - - let earlyExitError: Error | null = null; - child.on("exit", (code) => { - if (code !== 0 && code !== null) { - earlyExitError = new Error( - `emulator binary exited with code ${code} before the device booted. ` + - `Common causes: AVD corrupted, Hypervisor unavailable, or disk full. ` + - `Try \`emulator -avd ${params.avdName} -verbose\` from a terminal to see the exact error.` - ); - } - }); - - // Ensure adb daemon is up so the new device socket registers promptly. - await runAdb(["start-server"], { timeoutMs: 10_000 }).catch(() => {}); - - // ── Stage 2: wait for adb to see the new emulator ───────────────── - let serial: string | null = null; - const adbDeadline = Math.min(overallDeadline, Date.now() + STAGE_BUDGET.adbRegister); - while (Date.now() < adbDeadline) { - if (earlyExitError) { - throw earlyExitError; - } - const newSerials = await listNewEmulatorSerials(serialsBefore); - if (newSerials.length >= 1) { - // If exactly one new emulator, adopt its serial. If multiple, prefer the - // AVD-name match. - if (newSerials.length === 1) { - serial = newSerials[0]!; - break; - } - const byAvd = await findSerialByAvdName(params.avdName, Date.now() + 3_000); - if (byAvd) { - serial = byAvd; - break; - } - } - await new Promise((r) => setTimeout(r, 1_000)); - } - if (!serial) { - await killEmulatorQuietly(null); - throw new Error( - `Emulator "${params.avdName}" did not register with adb within ${STAGE_BUDGET.adbRegister / 1000}s. ` + - `Check that the Android SDK is on PATH and that no other emulator is already using the assigned port.` - ); - } - - // ── Stage 3: wait-for-device (tcp socket up) ────────────────────── - try { - await runAdb(["-s", serial, "wait-for-device"], { - timeoutMs: Math.min( - STAGE_BUDGET.deviceReady, - Math.max(1_000, overallDeadline - Date.now()) - ), - }); - } catch (err) { - await killEmulatorQuietly(serial); - throw new Error( - `adb wait-for-device failed for ${serial}: ${ - err instanceof Error ? err.message : String(err) - }. Emulator has been terminated; retry in a moment.` - ); - } - - // ── Stage 4: sys.boot_completed = 1 ─────────────────────────────── - const bootBudget = Math.max( - 10_000, - Math.min(STAGE_BUDGET.bootCompleted, overallDeadline - Date.now()) - ); - try { - await waitForBootCompleted(serial, bootBudget); - } catch (err) { - await killEmulatorQuietly(serial); - throw new Error( - `${err instanceof Error ? err.message : String(err)} ` + - `Emulator has been terminated so the next boot starts clean. ` + - `If this keeps happening, the AVD's snapshot may be corrupt — the tool already cold-boots by default, ` + - `but you can also manually wipe user data with \`emulator -avd ${params.avdName} -wipe-data\` from a shell.` - ); - } - - // ── Stage 5: one final sanity probe ─────────────────────────────── - // `pm` responds only after PackageManagerService is up. This prevents the - // tool from returning `booted: true` while subsequent `am start` / `pm list` - // calls would still 500 for ~10-30s. - try { - await adbShell(serial, "pm path android", { timeoutMs: 10_000 }); - } catch (err) { - await killEmulatorQuietly(serial); - throw new Error( - `PackageManager did not respond on ${serial} after boot_completed. ` + - `Emulator has been terminated. Retry the call.` - ); - } - - return { booted: true, serial, avdName: params.avdName, coldBoot }; - }, -}; diff --git a/packages/tool-server/src/tools/android/android-list-emulators.ts b/packages/tool-server/src/tools/android/android-list-emulators.ts deleted file mode 100644 index 33d88d98..00000000 --- a/packages/tool-server/src/tools/android/android-list-emulators.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; -import { listAndroidDevices, listAvds } from "../../utils/adb"; - -const zodSchema = z.object({}); - -export const androidListEmulatorsTool: ToolDefinition = { - id: "android-list-emulators", - description: - "List Android devices and emulators known to adb, plus available AVDs from `emulator -list-avds`. " + - "Use when you need a `serial` to pass to other android-* tools, or to check which emulators are already running. " + - "Returns { devices: [{ serial, state, isEmulator, model, avdName, sdkLevel }], avds: [{ name }] }. " + - "`state` is `device` (ready), `offline`, or `unauthorized`. " + - "Requires the Android SDK Platform Tools (adb) on PATH; AVD listing requires the Emulator package.", - zodSchema, - services: () => ({}), - async execute(_services, _params) { - const [devices, avds] = await Promise.all([listAndroidDevices(), listAvds()]); - // Sort ready devices first, then emulators before physical, for a predictable "pick the first" default. - devices.sort((a, b) => { - const aReady = a.state === "device" ? 0 : 1; - const bReady = b.state === "device" ? 0 : 1; - if (aReady !== bReady) return aReady - bReady; - const aEmu = a.isEmulator ? 0 : 1; - const bEmu = b.isEmulator ? 0 : 1; - return aEmu - bEmu; - }); - return { devices, avds }; - }, -}; diff --git a/packages/tool-server/src/tools/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts new file mode 100644 index 00000000..341b924b --- /dev/null +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -0,0 +1,280 @@ +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { z } from "zod"; +import type { Registry, ToolDefinition } from "@argent/registry"; +import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { + adbShell, + emulatorBinaryName, + listAndroidDevices, + listAvds, + runAdb, + waitForBootCompleted, +} from "../../utils/adb"; + +const execFileAsync = promisify(execFile); + +const zodSchema = z.object({ + udid: z + .string() + .optional() + .describe( + "iOS: simulator UDID to boot (from `list-devices`). Provide exactly one of `udid` or `avdName`." + ), + avdName: z + .string() + .optional() + .describe( + "Android: AVD name to launch a new emulator from (from `list-devices` → `avds[].name`). Provide exactly one of `udid` or `avdName`." + ), + coldBoot: z + .boolean() + .optional() + .describe( + "Android-only: skip the AVD snapshot and cold-boot. Defaults to true for reliability — corrupt snapshots are the leading cause of silent boot hangs. Ignored on iOS." + ), + noWindow: z + .boolean() + .optional() + .describe( + "Android-only: launch the emulator headless (no UI window). Useful for CI. Defaults to false so you can see boot progress. Ignored on iOS." + ), + bootTimeoutMs: z + .number() + .int() + .min(30_000) + .max(900_000) + .optional() + .describe( + "Android-only: overall budget for the full boot sequence. Defaults to 480000 (8 min). Clamped to [30s, 15min]. Ignored on iOS." + ), +}); + +type BootDeviceParams = z.infer; + +type BootDeviceResult = + | { platform: "ios"; udid: string; booted: true } + | { platform: "android"; serial: string; avdName: string; booted: true; coldBoot: boolean }; + +// Each stage has its own sub-budget so a hang in one stage cannot consume the +// entire overall budget and a bootTimeoutMs bump doesn't quietly mask a regression. +const STAGE_BUDGET = { + adbRegister: 60_000, // adb devices sees the serial for this AVD + deviceReady: 180_000, // adb -s wait-for-device returns (state === "device") + bootCompleted: 300_000, // sys.boot_completed = 1 +} as const; + +async function killEmulatorQuietly(serial: string | null): Promise { + if (serial) { + await runAdb(["-s", serial, "emu", "kill"], { timeoutMs: 5_000 }).catch(() => {}); + } +} + +async function findSerialByAvdName(avdName: string, deadline: number): Promise { + while (Date.now() < deadline) { + const devices = await listAndroidDevices().catch(() => []); + const match = devices.find((d) => d.isEmulator && d.avdName === avdName); + if (match) return match.serial; + await new Promise((r) => setTimeout(r, 1_500)); + } + return null; +} + +async function listNewEmulatorSerials(before: Set): Promise { + const { stdout } = await runAdb(["devices"]).catch(() => ({ stdout: "", stderr: "" })); + const lines = stdout.split("\n"); + const now: string[] = []; + for (const line of lines) { + const m = line.match(/^(emulator-\d+)\s+/); + if (m) now.push(m[1]!); + } + return now.filter((s) => !before.has(s)); +} + +async function bootIos( + udid: string, + registry: Registry +): Promise<{ platform: "ios"; udid: string; booted: true }> { + await execFileAsync("xcrun", ["simctl", "boot", udid]).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + // `simctl boot` errors when the device is already booted — treat as success. + if (!message.includes("Unable to boot device in current state: Booted")) { + throw err; + } + }); + // `bootstatus -b` blocks until the simulator is fully ready for env setup. + await execFileAsync("xcrun", ["simctl", "bootstatus", udid, "-b"]); + await registry.resolveService(`${NATIVE_DEVTOOLS_NAMESPACE}:${udid}`); + await execFileAsync("defaults", [ + "write", + "com.apple.iphonesimulator", + "CurrentDeviceUDID", + udid, + ]); + await execFileAsync("open", ["-a", "Simulator.app"]); + return { platform: "ios", udid, booted: true }; +} + +async function bootAndroid(params: { + avdName: string; + coldBoot: boolean; + noWindow: boolean; + bootTimeoutMs: number; +}): Promise<{ + platform: "android"; + serial: string; + avdName: string; + booted: true; + coldBoot: boolean; +}> { + const overallDeadline = Date.now() + params.bootTimeoutMs; + + // Stage 0: validate AVD exists + const avds = await listAvds(); + if (avds.length === 0) { + throw new Error( + "`emulator -list-avds` returned no AVDs. Install the Android Emulator package or create an AVD via Android Studio or `avdmanager create avd`." + ); + } + if (!avds.some((a) => a.name === params.avdName)) { + throw new Error( + `AVD "${params.avdName}" not found. Available: ${avds.map((a) => a.name).join(", ")}.` + ); + } + + const serialsBefore = new Set((await listAndroidDevices().catch(() => [])).map((d) => d.serial)); + + // Stage 1: spawn emulator + const emulatorArgs = ["-avd", params.avdName]; + if (params.coldBoot) emulatorArgs.push("-no-snapshot-load"); + if (params.noWindow) emulatorArgs.push("-no-window"); + + const child = spawn(emulatorBinaryName(), emulatorArgs, { + detached: true, + stdio: "ignore", + }); + child.unref(); + + let earlyExitError: Error | null = null; + child.on("exit", (code) => { + if (code !== 0 && code !== null) { + earlyExitError = new Error( + `emulator binary exited with code ${code} before the device booted. ` + + `Common causes: AVD corrupted, Hypervisor unavailable, or disk full. ` + + `Try \`emulator -avd ${params.avdName} -verbose\` from a terminal to see the exact error.` + ); + } + }); + + await runAdb(["start-server"], { timeoutMs: 10_000 }).catch(() => {}); + + // Stage 2: wait for adb to see the new emulator + let serial: string | null = null; + const adbDeadline = Math.min(overallDeadline, Date.now() + STAGE_BUDGET.adbRegister); + while (Date.now() < adbDeadline) { + if (earlyExitError) throw earlyExitError; + const newSerials = await listNewEmulatorSerials(serialsBefore); + if (newSerials.length >= 1) { + if (newSerials.length === 1) { + serial = newSerials[0]!; + break; + } + const byAvd = await findSerialByAvdName(params.avdName, Date.now() + 3_000); + if (byAvd) { + serial = byAvd; + break; + } + } + await new Promise((r) => setTimeout(r, 1_000)); + } + if (!serial) { + await killEmulatorQuietly(null); + throw new Error( + `Emulator "${params.avdName}" did not register within ${STAGE_BUDGET.adbRegister / 1000}s. ` + + `Check that the Android SDK is on PATH and that no other emulator is already using the assigned port.` + ); + } + + // Stage 3: wait-for-device (tcp socket up) + try { + await runAdb(["-s", serial, "wait-for-device"], { + timeoutMs: Math.min(STAGE_BUDGET.deviceReady, Math.max(1_000, overallDeadline - Date.now())), + }); + } catch (err) { + await killEmulatorQuietly(serial); + throw new Error( + `adb wait-for-device failed for ${serial}: ${ + err instanceof Error ? err.message : String(err) + }. Emulator has been terminated; retry in a moment.` + ); + } + + // Stage 4: sys.boot_completed = 1 + const bootBudget = Math.max( + 10_000, + Math.min(STAGE_BUDGET.bootCompleted, overallDeadline - Date.now()) + ); + try { + await waitForBootCompleted(serial, bootBudget); + } catch (err) { + await killEmulatorQuietly(serial); + throw new Error( + `${err instanceof Error ? err.message : String(err)} ` + + `Emulator has been terminated so the next boot starts clean. ` + + `If this keeps happening, the AVD's snapshot may be corrupt — the tool already cold-boots by default, ` + + `but you can also manually wipe user data with \`emulator -avd ${params.avdName} -wipe-data\` from a shell.` + ); + } + + // Stage 5: PackageManagerService sanity probe — protects callers from a + // race where boot_completed fires but `am start` would still 500 for 10-30s. + try { + await adbShell(serial, "pm path android", { timeoutMs: 10_000 }); + } catch { + await killEmulatorQuietly(serial); + throw new Error( + `PackageManager did not respond on ${serial} after boot_completed. ` + + `Emulator has been terminated. Retry the call.` + ); + } + + return { + platform: "android", + serial, + avdName: params.avdName, + booted: true, + coldBoot: params.coldBoot, + }; +} + +export function createBootDeviceTool( + registry: Registry +): ToolDefinition { + return { + id: "boot-device", + description: + "Start an iOS simulator or launch an Android emulator and wait until it is ready to accept interactions. " + + "Pick the platform by which argument you pass: `udid` for an iOS simulator from `list-devices`, or `avdName` for an Android AVD (a serial is assigned automatically). " + + "Use at the start of a session once you have picked a target. " + + "Returns a tagged payload: `{ platform: 'ios', udid, booted }` or `{ platform: 'android', serial, avdName, booted, coldBoot }`. " + + "Android boots take 2–10 minutes depending on machine and cold/warm state; if any boot stage fails, the tool terminates the emulator it spawned so the next retry starts clean.", + zodSchema, + services: () => ({}), + async execute(_services, params) { + const hasUdid = Boolean(params.udid); + const hasAvd = Boolean(params.avdName); + if (hasUdid === hasAvd) { + throw new Error("Provide exactly one of `udid` (iOS) or `avdName` (Android)."); + } + if (hasUdid) { + return bootIos(params.udid!, registry); + } + return bootAndroid({ + avdName: params.avdName!, + coldBoot: params.coldBoot ?? true, + noWindow: params.noWindow ?? false, + bootTimeoutMs: params.bootTimeoutMs ?? 480_000, + }); + }, + }; +} diff --git a/packages/tool-server/src/tools/devices/list-devices.ts b/packages/tool-server/src/tools/devices/list-devices.ts new file mode 100644 index 00000000..6b3b603b --- /dev/null +++ b/packages/tool-server/src/tools/devices/list-devices.ts @@ -0,0 +1,119 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { z } from "zod"; +import type { ToolDefinition } from "@argent/registry"; +import { listAndroidDevices, listAvds } from "../../utils/adb"; + +const execFileAsync = promisify(execFile); + +type IosDevice = { + platform: "ios"; + udid: string; + name: string; + state: string; + runtime: string; +}; + +type AndroidDevice = { + platform: "android"; + serial: string; + state: string; + isEmulator: boolean; + model: string | null; + avdName: string | null; + sdkLevel: number | null; +}; + +type ListDevicesResult = { + devices: Array; + avds: Array<{ name: string }>; +}; + +interface SimctlDevice { + udid: string; + name: string; + state: string; + deviceTypeIdentifier: string; + isAvailable: boolean; +} + +interface SimctlOutput { + devices: Record; +} + +async function listIosSimulators(): Promise { + try { + const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"], { + timeout: 10_000, + }); + const data: SimctlOutput = JSON.parse(stdout); + const out: IosDevice[] = []; + for (const [runtimeId, devices] of Object.entries(data.devices)) { + if (!runtimeId.includes("iOS")) continue; + for (const d of devices) { + if (!d.isAvailable) continue; + out.push({ + platform: "ios", + udid: d.udid, + name: d.name, + state: d.state, + runtime: runtimeId, + }); + } + } + return out; + } catch { + // macOS without Xcode, or non-mac host — no iOS devices to report + return []; + } +} + +function sortIos(a: IosDevice, b: IosDevice): number { + const aBooted = a.state === "Booted" ? 0 : 1; + const bBooted = b.state === "Booted" ? 0 : 1; + if (aBooted !== bBooted) return aBooted - bBooted; + const aIpad = a.name.includes("iPad") ? 1 : 0; + const bIpad = b.name.includes("iPad") ? 1 : 0; + return aIpad - bIpad; +} + +function sortAndroid(a: AndroidDevice, b: AndroidDevice): number { + const aReady = a.state === "device" ? 0 : 1; + const bReady = b.state === "device" ? 0 : 1; + if (aReady !== bReady) return aReady - bReady; + const aEmu = a.isEmulator ? 0 : 1; + const bEmu = b.isEmulator ? 0 : 1; + return aEmu - bEmu; +} + +const zodSchema = z.object({}); + +export const listDevicesTool: ToolDefinition, ListDevicesResult> = { + id: "list-devices", + description: + "List iOS simulators and Android devices/emulators in one place. " + + "Use at the start of a session to pick a target id (`udid` for iOS entries, `serial` for Android) to pass to interaction tools, and to see which targets are already running. " + + "Returns { devices, avds } where each device carries a `platform` discriminator (`ios` or `android`), and `avds` lists Android AVDs that can be booted via `boot-device`. " + + "Booted/ready devices are listed first. Platforms whose tooling is unavailable (no Xcode on macOS, no adb on PATH) are silently omitted — run the relevant installer if the list is empty.", + zodSchema, + services: () => ({}), + async execute(_services, _params) { + const [ios, android, avds] = await Promise.all([ + listIosSimulators(), + listAndroidDevices().catch(() => []), + listAvds(), + ]); + ios.sort(sortIos); + const androidTagged: AndroidDevice[] = android.map((d) => ({ + platform: "android", + serial: d.serial, + state: d.state, + isEmulator: d.isEmulator, + model: d.model, + avdName: d.avdName, + sdkLevel: d.sdkLevel, + })); + androidTagged.sort(sortAndroid); + return { devices: [...ios, ...androidTagged], avds }; + }, +}; diff --git a/packages/tool-server/src/tools/simulator/boot-simulator.ts b/packages/tool-server/src/tools/simulator/boot-simulator.ts deleted file mode 100644 index 249f4ada..00000000 --- a/packages/tool-server/src/tools/simulator/boot-simulator.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { z } from "zod"; -import type { Registry, ToolDefinition } from "@argent/registry"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; - -const execFileAsync = promisify(execFile); - -const zodSchema = z.object({ - udid: z.string().describe("The UDID of the simulator to boot"), -}); - -export function createBootSimulatorTool( - registry: Registry -): ToolDefinition<{ udid: string }, { udid: string; booted: boolean }> { - return { - id: "boot-simulator", - description: - "Start an iOS simulator by UDID. Use when the target simulator is in Shutdown state before starting a session. Returns when the simulator is ready. Fails if the UDID is invalid or Xcode tools are not installed.", - zodSchema, - services: () => ({}), - async execute(_services, params, _options) { - const bootPromise = execFileAsync("xcrun", ["simctl", "boot", params.udid]).catch( - (err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - // xcrun simctl boot exits with an error if the device is already booted — treat as success - if (!message.includes("Unable to boot device in current state: Booted")) { - throw err; - } - } - ); - await bootPromise; - // `simctl bootstatus -b` blocks until the simulator has fully booted and can - // accept the launchd env setup performed by NativeDevtools service init. - await execFileAsync("xcrun", ["simctl", "bootstatus", params.udid, "-b"]); - await registry.resolveService(`${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`); - // Write the preference before opening so it applies to both fresh launches and - // already-running instances. `open --args` is ignored when the app is already running. - await execFileAsync("defaults", [ - "write", - "com.apple.iphonesimulator", - "CurrentDeviceUDID", - params.udid, - ]); - await execFileAsync("open", ["-a", "Simulator.app"]); - return { udid: params.udid, booted: true }; - }, - }; -} diff --git a/packages/tool-server/src/tools/simulator/list-simulators.ts b/packages/tool-server/src/tools/simulator/list-simulators.ts deleted file mode 100644 index 90e5f3a5..00000000 --- a/packages/tool-server/src/tools/simulator/list-simulators.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; - -const execFileAsync = promisify(execFile); - -interface SimctlDevice { - udid: string; - name: string; - state: string; - deviceTypeIdentifier: string; - isAvailable: boolean; -} - -interface SimctlOutput { - devices: Record; -} - -const zodSchema = z.object({}); - -export const listSimulatorsTool: ToolDefinition = { - id: "list-simulators", - description: - "List all available iOS simulators with their current state. Use when you need a UDID or want to see which simulators are Booted vs Shutdown. Returns an array of simulators with udid, name, state, runtime, and isAvailable. Fails if Xcode command-line tools are not installed.", - zodSchema, - services: () => ({}), - async execute(_services, _params, _options) { - const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"]); - const data: SimctlOutput = JSON.parse(stdout); - const simulators: { - udid: string; - name: string; - state: string; - runtime: string; - isAvailable: boolean; - }[] = []; - - for (const [runtimeId, devices] of Object.entries(data.devices)) { - if (!runtimeId.includes("iOS")) continue; - for (const device of devices) { - if (!device.isAvailable) continue; - simulators.push({ - udid: device.udid, - name: device.name, - state: device.state, - runtime: runtimeId, - isAvailable: device.isAvailable, - }); - } - } - - simulators.sort((a, b) => { - const aBooted = a.state === "Booted" ? 0 : 1; - const bBooted = b.state === "Booted" ? 0 : 1; - if (aBooted !== bBooted) return aBooted - bBooted; - const aIpad = a.name.includes("iPad") ? 1 : 0; - const bIpad = b.name.includes("iPad") ? 1 : 0; - return aIpad - bIpad; - }); - - return { simulators }; - }, -}; diff --git a/packages/tool-server/src/utils/setup-registry.ts b/packages/tool-server/src/utils/setup-registry.ts index 618c69d4..db0d794c 100644 --- a/packages/tool-server/src/utils/setup-registry.ts +++ b/packages/tool-server/src/utils/setup-registry.ts @@ -12,8 +12,8 @@ import { nativeUserInteractableViewAtPointTool } from "../tools/native-devtools/ import { jsRuntimeDebuggerBlueprint } from "../blueprints/js-runtime-debugger"; import { networkInspectorBlueprint } from "../blueprints/network-inspector"; import { reactProfilerSessionBlueprint } from "../blueprints/react-profiler-session"; -import { listSimulatorsTool } from "../tools/simulator/list-simulators"; -import { createBootSimulatorTool } from "../tools/simulator/boot-simulator"; +import { listDevicesTool } from "../tools/devices/list-devices"; +import { createBootDeviceTool } from "../tools/devices/boot-device"; import { launchAppTool } from "../tools/simulator/launch-app"; import { restartAppTool } from "../tools/simulator/restart-app"; import { reinstallAppTool } from "../tools/simulator/reinstall-app"; @@ -66,8 +66,6 @@ import { flowReadPrerequisiteTool } from "../tools/flows/flow-read-prerequisite" import { gatherWorkspaceDataTool } from "../tools/workspace/gather-workspace-data"; import { updateArgentTool } from "../tools/system/update-argent"; import { dismissUpdateTool } from "../tools/system/dismiss-update"; -import { androidListEmulatorsTool } from "../tools/android/android-list-emulators"; -import { androidBootEmulatorTool } from "../tools/android/android-boot-emulator"; import { androidStopAppTool } from "../tools/android/android-stop-app"; import { androidLogcatTool } from "../tools/android/android-logcat"; @@ -82,8 +80,8 @@ export function createRegistry(): Registry { registry.registerBlueprint(nativeDevtoolsBlueprint); registry.registerBlueprint(axServiceBlueprint); - registry.registerTool(listSimulatorsTool); - registry.registerTool(createBootSimulatorTool(registry)); + registry.registerTool(listDevicesTool); + registry.registerTool(createBootDeviceTool(registry)); registry.registerTool(launchAppTool); registry.registerTool(restartAppTool); registry.registerTool(reinstallAppTool); @@ -149,11 +147,9 @@ export function createRegistry(): Registry { registry.registerTool(updateArgentTool); registry.registerTool(dismissUpdateTool); - // Android-only tools. Tools that exist on both platforms are exposed under - // their unified names above (screenshot, gesture-tap, describe, launch-app, - // etc.) and dispatch internally on udid shape; see utils/platform-detect.ts. - registry.registerTool(androidListEmulatorsTool); - registry.registerTool(androidBootEmulatorTool); + // Android-only tools. Cross-platform tools live under unified names (list-devices, + // boot-device, screenshot, gesture-tap, describe, launch-app, ...) and dispatch + // on the id shape; see utils/platform-detect.ts. registry.registerTool(androidStopAppTool); registry.registerTool(androidLogcatTool); diff --git a/packages/tool-server/test/boot-simulator.test.ts b/packages/tool-server/test/boot-device.test.ts similarity index 56% rename from packages/tool-server/test/boot-simulator.test.ts rename to packages/tool-server/test/boot-device.test.ts index cd52a14b..1db09993 100644 --- a/packages/tool-server/test/boot-simulator.test.ts +++ b/packages/tool-server/test/boot-device.test.ts @@ -13,13 +13,17 @@ function getCallback(args: unknown[]): ExecFileCallback { return callback as ExecFileCallback; } -vi.mock("node:child_process", () => ({ - execFile: (...args: unknown[]) => mockExecFile(...args), -})); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: (...args: unknown[]) => mockExecFile(...args), + }; +}); -import { createBootSimulatorTool } from "../src/tools/simulator/boot-simulator"; +import { createBootDeviceTool } from "../src/tools/devices/boot-device"; -describe("boot-simulator tool", () => { +describe("boot-device — iOS path (previously boot-simulator)", () => { beforeEach(() => { vi.clearAllMocks(); mockExecFile.mockImplementation((...args: unknown[]) => { @@ -34,11 +38,12 @@ describe("boot-simulator tool", () => { resolveService, } as unknown as Registry; - const tool = createBootSimulatorTool(registry); + const tool = createBootDeviceTool(registry); await expect( tool.execute!({}, { udid: "11111111-1111-1111-1111-111111111111" }) ).resolves.toEqual({ + platform: "ios", udid: "11111111-1111-1111-1111-111111111111", booted: true, }); @@ -60,6 +65,9 @@ describe("boot-simulator tool", () => { expect(resolveService).toHaveBeenCalledWith( "NativeDevtools:11111111-1111-1111-1111-111111111111" ); + // NativeDevtools must be primed AFTER bootstatus returns (launchd env is + // only reachable once the simulator is fully up) and BEFORE `open`, so + // the UI reflects the injected state on first paint. expect(resolveService.mock.invocationCallOrder[0]).toBeGreaterThan( mockExecFile.mock.invocationCallOrder[1] ); @@ -82,11 +90,12 @@ describe("boot-simulator tool", () => { const resolveService = vi.fn(async () => {}); const registry = { resolveService } as unknown as Registry; - const tool = createBootSimulatorTool(registry); + const tool = createBootDeviceTool(registry); await expect( tool.execute!({}, { udid: "22222222-2222-2222-2222-222222222222" }) ).resolves.toEqual({ + platform: "ios", udid: "22222222-2222-2222-2222-222222222222", booted: true, }); @@ -100,3 +109,36 @@ describe("boot-simulator tool", () => { ); }); }); + +describe("boot-device — input validation (exclusive udid/avdName)", () => { + // The zodSchema marks both udid and avdName as optional so the JSON schema + // advertises both; the execute function enforces that exactly one is set. + // These tests pin the mutual-exclusion rule at the execute boundary where + // callers actually hit it. + + it("rejects when both udid and avdName are provided — ambiguous target", async () => { + const tool = createBootDeviceTool({ resolveService: async () => {} } as unknown as Registry); + await expect( + tool.execute!( + {}, + { + udid: "11111111-1111-1111-1111-111111111111", + avdName: "Pixel_7_API_34", + } + ) + ).rejects.toThrow(/exactly one of `udid` .* or `avdName`/); + }); + + it("rejects when neither udid nor avdName is provided — no target", async () => { + const tool = createBootDeviceTool({ resolveService: async () => {} } as unknown as Registry); + await expect(tool.execute!({}, {})).rejects.toThrow(/exactly one of `udid`/); + }); + + it("bounds bootTimeoutMs to [30s, 15min]", () => { + // Timeouts should fail at the zod layer before reaching execute. + const tool = createBootDeviceTool({} as unknown as Registry); + expect(tool.zodSchema.safeParse({ avdName: "x", bootTimeoutMs: 29_999 }).success).toBe(false); + expect(tool.zodSchema.safeParse({ avdName: "x", bootTimeoutMs: 900_001 }).success).toBe(false); + expect(tool.zodSchema.safeParse({ avdName: "x", bootTimeoutMs: 60_000 }).success).toBe(true); + }); +}); diff --git a/packages/tool-server/test/list-devices.test.ts b/packages/tool-server/test/list-devices.test.ts new file mode 100644 index 00000000..80acd47e --- /dev/null +++ b/packages/tool-server/test/list-devices.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const execFileMock = vi.fn(); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { listDevicesTool } from "../src/tools/devices/list-devices"; + +function simctlJson(): string { + return JSON.stringify({ + devices: { + "com.apple.CoreSimulator.SimRuntime.iOS-18-2": [ + { + udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + name: "iPhone 16", + state: "Booted", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + isAvailable: true, + }, + { + udid: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + name: "iPad Pro", + state: "Shutdown", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro", + isAvailable: true, + }, + { + udid: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + name: "iPhone 16 (unavailable)", + state: "Shutdown", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + isAvailable: false, + }, + ], + "com.apple.CoreSimulator.SimRuntime.tvOS-17-5": [ + { + udid: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD", + name: "Apple TV", + state: "Shutdown", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.Apple-TV", + isAvailable: true, + }, + ], + }, + }); +} + +beforeEach(() => { + execFileMock.mockReset(); +}); + +describe("list-devices", () => { + it("merges iOS simulators and Android devices into a single tagged array", async () => { + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "xcrun" && args[0] === "simctl" && args[1] === "list") { + return { stdout: simctlJson(), stderr: "" }; + } + if (cmd === "adb" && args[0] === "devices") { + return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" }; + } + if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") { + const shellCmd = args[3] ?? ""; + if (shellCmd.includes("ro.product.model")) return { stdout: "Pixel_3a\n", stderr: "" }; + if (shellCmd.includes("ro.build.version.sdk")) return { stdout: "34\n", stderr: "" }; + if (shellCmd.includes("ro.kernel.qemu.avd_name")) + return { stdout: "Pixel_3a_API_34\n", stderr: "" }; + } + if (cmd === "emulator" && args[0] === "-list-avds") { + return { stdout: "Pixel_3a_API_34\nPixel_7_API_34\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + const result = await listDevicesTool.execute!({}, {}); + + // Every device has a `platform` discriminator; there is no separate iOS/Android + // list the caller has to merge. + for (const d of result.devices) { + expect(d.platform === "ios" || d.platform === "android").toBe(true); + } + + const ios = result.devices.filter((d) => d.platform === "ios") as Array<{ + platform: "ios"; + udid: string; + name: string; + state: string; + }>; + // Unavailable simulators are filtered out; tvOS is filtered out (non-iOS runtime). + expect(ios.map((d) => d.name).sort()).toEqual(["iPad Pro", "iPhone 16"]); + // Booted iOS devices come before shut-down ones. + expect(ios[0]!.state).toBe("Booted"); + expect(ios[0]!.name).toBe("iPhone 16"); + + const android = result.devices.filter((d) => d.platform === "android") as Array<{ + platform: "android"; + serial: string; + sdkLevel: number | null; + avdName: string | null; + isEmulator: boolean; + }>; + expect(android).toHaveLength(1); + expect(android[0]).toMatchObject({ + serial: "emulator-5554", + sdkLevel: 34, + avdName: "Pixel_3a_API_34", + isEmulator: true, + }); + + // AVDs list comes from `emulator -list-avds`. + expect(result.avds).toEqual([{ name: "Pixel_3a_API_34" }, { name: "Pixel_7_API_34" }]); + }); + + it("silently omits iOS when xcrun is unavailable — other platforms still returned", async () => { + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "xcrun") { + return new Error("xcrun: error: invalid active developer path"); + } + if (cmd === "adb" && args[0] === "devices") { + return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" }; + } + if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") { + return { stdout: "", stderr: "" }; + } + if (cmd === "emulator") { + return { stdout: "Pixel_3a_API_34\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + const result = await listDevicesTool.execute!({}, {}); + expect(result.devices.filter((d) => d.platform === "ios")).toHaveLength(0); + expect(result.devices.filter((d) => d.platform === "android")).toHaveLength(1); + expect(result.avds.length).toBeGreaterThan(0); + }); + + it("silently omits Android when adb is unavailable — iOS still returned", async () => { + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "xcrun" && args[0] === "simctl") { + return { stdout: simctlJson(), stderr: "" }; + } + if (cmd === "adb") { + return new Error("adb: command not found"); + } + if (cmd === "emulator") { + return new Error("emulator: command not found"); + } + return { stdout: "", stderr: "" }; + }); + + const result = await listDevicesTool.execute!({}, {}); + expect(result.devices.filter((d) => d.platform === "android")).toHaveLength(0); + expect(result.devices.filter((d) => d.platform === "ios").length).toBeGreaterThan(0); + expect(result.avds).toEqual([]); + }); +}); From 05a619435dd8bae4b5121375b892e28d7b8137d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 13:36:35 +0200 Subject: [PATCH 005/149] refactor(descriptions): drop implementation-detail leaks from tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool descriptions should tell the caller what the tool does, when to use it, and what it returns — not which binary or protocol drives it. Strips references to `simulator-server`, `xcrun`, `adb`, `uiautomator`, `AXRuntime`, and `USB HID` from descriptions that the agent reads when picking a tool. The behavior itself is unchanged; implementation details stay in the code (where they belong) and in the skill docs where they actually inform workflow. Also updates `udid` parameter descriptions to point at `list-devices` as the canonical source, rather than restating the platform-shape heuristic in every tool. --- packages/mcp/src/mcp-server.ts | 6 ++--- packages/mcp/test/auto-screenshot.test.ts | 4 ++-- .../tools/debugger/debugger-component-tree.ts | 2 +- .../src/tools/interactions/button.ts | 13 ++++------- .../src/tools/interactions/describe.ts | 22 ++++++------------ .../src/tools/interactions/gesture-custom.ts | 6 ++--- .../src/tools/interactions/gesture-pinch.ts | 12 +++++----- .../src/tools/interactions/gesture-rotate.ts | 12 +++++----- .../src/tools/interactions/gesture-swipe.ts | 15 ++++-------- .../src/tools/interactions/gesture-tap.ts | 14 ++++------- .../src/tools/interactions/keyboard.ts | 14 +++++------ .../src/tools/interactions/run-sequence.ts | 12 ++++------ .../src/tools/interactions/screenshot.ts | 11 +++------ .../src/tools/simulator/launch-app.ts | 20 +++++----------- .../src/tools/simulator/open-url.ts | 15 ++++-------- .../src/tools/simulator/reinstall-app.ts | 23 ++++++------------- .../src/tools/simulator/restart-app.ts | 13 ++++------- .../tool-server/src/tools/simulator/rotate.ts | 10 ++++---- 18 files changed, 82 insertions(+), 142 deletions(-) diff --git a/packages/mcp/src/mcp-server.ts b/packages/mcp/src/mcp-server.ts index 90a2d4a9..18debd28 100644 --- a/packages/mcp/src/mcp-server.ts +++ b/packages/mcp/src/mcp-server.ts @@ -123,10 +123,10 @@ export async function startMcpServer(): Promise { capabilities: { tools: {} }, instructions: "Argent — iOS Simulator + Android Emulator control for interacting, testing, profiling and debugging mobile apps. " + - "Interaction tools (`gesture-tap`, `gesture-swipe`, `button`, `keyboard`, `rotate`, `screenshot`, `describe`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `run-sequence`) accept a `udid` and auto-dispatch iOS vs Android based on the id's shape (UUID → iOS, anything else → Android adb serial). " + - "Android-specific extras: `android-list-emulators`, `android-boot-emulator`, `android-stop-app`, `android-logcat`. iOS-specific: `list-simulators`, `boot-simulator`, `stop-simulator-server`, `stop-all-simulator-servers`, native-devtools suite, iOS Instruments profiler. " + + "Use `list-devices` to pick a target and `boot-device` to start it. Interaction tools (`gesture-tap`, `gesture-swipe`, `button`, `keyboard`, `rotate`, `screenshot`, `describe`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `run-sequence`) accept a `udid` and auto-dispatch by the id's shape (UUID → iOS, anything else → Android adb serial). " + + "Android-specific extras: `android-stop-app`, `android-logcat`. iOS-specific extras: `stop-simulator-server`, `stop-all-simulator-servers`, native-devtools suite, iOS Instruments profiler. " + "Always use `describe` / `debugger-component-tree` / `screenshot` before tapping — never guess coordinates. " + - "On session end: call `stop-all-simulator-servers` for iOS and any necessary Android cleanup. " + + "On session end: call `stop-all-simulator-servers` for iOS and kill the Android emulator via its UI or `adb -s emu kill`. " + "Full guidance is in the argent rule loaded from .claude/rules/argent.md.", } ); diff --git a/packages/mcp/test/auto-screenshot.test.ts b/packages/mcp/test/auto-screenshot.test.ts index 3ec14f9f..7dcdec95 100644 --- a/packages/mcp/test/auto-screenshot.test.ts +++ b/packages/mcp/test/auto-screenshot.test.ts @@ -83,8 +83,8 @@ describe("shouldAutoScreenshot", () => { }); it("returns false for excluded tools", () => { - expect(shouldAutoScreenshot("list-simulators")).toBe(false); - expect(shouldAutoScreenshot("boot-simulator")).toBe(false); + expect(shouldAutoScreenshot("list-devices")).toBe(false); + expect(shouldAutoScreenshot("boot-device")).toBe(false); expect(shouldAutoScreenshot("simulator-server")).toBe(false); expect(shouldAutoScreenshot("activate-sso")).toBe(false); }); diff --git a/packages/tool-server/src/tools/debugger/debugger-component-tree.ts b/packages/tool-server/src/tools/debugger/debugger-component-tree.ts index 800ff1b9..2fafb229 100644 --- a/packages/tool-server/src/tools/debugger/debugger-component-tree.ts +++ b/packages/tool-server/src/tools/debugger/debugger-component-tree.ts @@ -519,7 +519,7 @@ Only shows on-screen components with unique positions — off-screen (scrolled) full-screen transparent wrappers, and implementation-detail components are pruned. Each visible component is listed with its name, text content, and normalized -tap coordinates in [0,1] space (fractions of the screen, not pixels—same space as tap/swipe/gesture and simulator-server touch). +tap coordinates in [0,1] space (fractions of the screen, not pixels — same space as tap/swipe/gesture). This is the preferred element discovery tool for React Native apps. More information in react-native-app-workflow skill. diff --git a/packages/tool-server/src/tools/interactions/button.ts b/packages/tool-server/src/tools/interactions/button.ts index 2806c335..946d103b 100644 --- a/packages/tool-server/src/tools/interactions/button.ts +++ b/packages/tool-server/src/tools/interactions/button.ts @@ -6,11 +6,7 @@ import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), button: z .enum(["home", "back", "power", "volumeUp", "volumeDown", "appSwitch", "actionButton"]) .describe("Hardware button to press"), @@ -18,9 +14,10 @@ const zodSchema = z.object({ export const buttonTool: ToolDefinition, { pressed: string }> = { id: "button", - description: `Press a hardware button on iOS or Android. Sends Down then Up events automatically. -Supported: home, back, power, volumeUp, volumeDown, appSwitch, actionButton. The simulator-server binary maps these to each platform's native keycode internally. -Returns { pressed: buttonName }. Fails if the simulator server cannot start.`, + description: `Press a hardware button. Sends Down then Up automatically. +Supported: home, back, power, volumeUp, volumeDown, appSwitch, actionButton. +Use when you need to trigger a hardware-button event (e.g. Android back, iOS home, volume). +Returns { pressed }. Fails if the target device is not booted.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/describe.ts b/packages/tool-server/src/tools/interactions/describe.ts index 90a4bb1f..eed4bc2b 100644 --- a/packages/tool-server/src/tools/interactions/describe.ts +++ b/packages/tool-server/src/tools/interactions/describe.ts @@ -15,17 +15,12 @@ import { getAndroidScreenSize } from "../../utils/android-screen"; import { parseUiAutomatorDump } from "../../utils/uiautomator-parser"; const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() .optional() .describe( - "iOS-only: target hint when AX-service returns nothing and the tool falls back to native-devtools inspection. " + - "If omitted, falls back to the frontmost connected app. Ignored on Android." + "iOS-only: target hint for the fallback app-level inspection when the top-level describe returns nothing. If omitted, the frontmost connected app is used. Ignored on Android." ), }); @@ -56,14 +51,11 @@ export function createDescribeTool( ): ToolDefinition, DescribeResult> { return { id: "describe", - description: `Get the UI hierarchy for the current screen on iOS or Android. - -iOS: accessibility element tree from AXRuntime. Returns dialog elements when a system modal is visible, otherwise the foreground app's accessible tree. Falls back to native-devtools inspection if AX is empty. -Android: uiautomator dump parsed into the same DescribeNode shape. Uses \`resource-id\` as identifier, \`content-desc\`/\`text\` as label. - -Both return frame coordinates normalized to [0,1] — same coord space as gesture-tap. Use frame.x + frame.width/2 as tap X, frame.y + frame.height/2 as tap Y. - -For React Native apps on either platform, \`debugger-component-tree\` returns richer component data (requires Metro connection; on Android also requires \`adb reverse tcp:8081 tcp:8081\`).`, + description: `Get the current screen's UI hierarchy as a tree of elements with roles, labels, identifiers, values, and frame coordinates. +Returns dialog elements when a system modal is visible, otherwise the foreground app's elements. +Frame coordinates are normalized to [0,1] — same space as gesture-tap. Use frame.x + frame.width/2 as tap X, frame.y + frame.height/2 as tap Y. +For React Native apps, prefer \`debugger-component-tree\` when a Metro debugger connection is available — it returns richer component-level data. +Call before every tap — never guess coordinates from a screenshot.`, zodSchema, services: () => ({}), async execute(_services, params, _options) { diff --git a/packages/tool-server/src/tools/interactions/gesture-custom.ts b/packages/tool-server/src/tools/interactions/gesture-custom.ts index 6709f011..1d01eecd 100644 --- a/packages/tool-server/src/tools/interactions/gesture-custom.ts +++ b/packages/tool-server/src/tools/interactions/gesture-custom.ts @@ -25,7 +25,7 @@ const eventSchema = z.object({ }); const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), events: z .array(eventSchema) .describe( @@ -47,9 +47,9 @@ export const gestureCustomTool: ToolDefinition, { even Use for: long press, drag-and-drop, custom scroll, pinch (second touch point). For simple taps use the gesture-tap tool. For straight-line scrolling use the gesture-swipe tool. For pinch gestures use gesture-pinch. For rotation gestures use gesture-rotate. -All x/y values are normalized 0.0–1.0 (screen fractions, not pixels), matching simulator-server touch input. delayMs controls the delay before each event (default 16ms ≈ 60fps). +All x/y values are normalized 0.0–1.0 (screen fractions, not pixels). delayMs controls the delay before each event (default 16ms ≈ 60fps). Set interpolate to auto-generate smooth intermediate Move events between your keyframes. -Returns { events: number } with the total count of events dispatched. Fails if the simulator server is not running or an event type is invalid. +Returns { events: number } with the total count of events dispatched. Fails if the target device is not booted or an event type is invalid. Example long-press at center: [{"type":"Down","x":0.5,"y":0.5},{"type":"Up","x":0.5,"y":0.5,"delayMs":800}] diff --git a/packages/tool-server/src/tools/interactions/gesture-pinch.ts b/packages/tool-server/src/tools/interactions/gesture-pinch.ts index eef7100f..237567a5 100644 --- a/packages/tool-server/src/tools/interactions/gesture-pinch.ts +++ b/packages/tool-server/src/tools/interactions/gesture-pinch.ts @@ -4,7 +4,7 @@ import type { SimulatorServerApi } from "../../blueprints/simulator-server"; import { sleep, sendTouchEvent } from "../../utils/gesture-utils"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), centerX: z .number() .describe( @@ -44,11 +44,11 @@ export const gesturePinchTool: ToolDefinition< { pinched: boolean; timestampMs: number } > = { id: "gesture-pinch", - description: `Execute a pinch-to-zoom gesture by moving two fingers toward or away from a center point to change the scale of on-screen content. All positions and distances are normalized 0.0–1.0 (fractions of screen width/height, not pixels)—same coordinate space as gesture-tap and gesture-swipe. -startDistance > endDistance = pinch in (zoom out). startDistance < endDistance = pinch out (zoom in). -Typical values: startDistance 0.2, endDistance 0.6 for a zoom-in pinch at screen center. -Auto-generates interpolated frames at ~60fps. The angle parameter controls the axis (0 = horizontal, 90 = vertical). -Use when you need to zoom in or out on a map, image, or zoomable view. Returns { pinched: true, timestampMs }. Fails if the simulator server is not running for the given UDID.`, + description: `Two-finger pinch-to-zoom at a center point. All positions and distances are normalized 0.0–1.0 (fractions of the screen, not pixels). +startDistance > endDistance = pinch in (zoom out); startDistance < endDistance = pinch out (zoom in). +Typical zoom-in: startDistance 0.2, endDistance 0.6 at screen center. +\`angle\` controls the axis in degrees (0 = horizontal, 90 = vertical). +Use to zoom a map, image, or zoomable view. Returns { pinched, timestampMs }. Fails if the target device is not booted.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/gesture-rotate.ts b/packages/tool-server/src/tools/interactions/gesture-rotate.ts index c1bd0320..7c2600b7 100644 --- a/packages/tool-server/src/tools/interactions/gesture-rotate.ts +++ b/packages/tool-server/src/tools/interactions/gesture-rotate.ts @@ -4,7 +4,7 @@ import type { SimulatorServerApi } from "../../blueprints/simulator-server"; import { sleep, sendTouchEvent } from "../../utils/gesture-utils"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), centerX: z .number() .describe( @@ -34,11 +34,11 @@ export const gestureRotateTool: ToolDefinition< { rotated: boolean; timestampMs: number } > = { id: "gesture-rotate", - description: `Send a two-finger circular arc gesture to rotate on-screen content by a specified angle. Two fingers are placed opposite each other at a fixed radius from the center, then swept from startAngle to endAngle degrees. All positions and radius are normalized 0.0–1.0 (fractions of screen width/height, not pixels)—same coordinate space as gesture-tap and gesture-swipe. -endAngle > startAngle = clockwise rotation. Typical values: radius 0.15, startAngle 0, endAngle 90 for a 90° clockwise turn. -Auto-generates interpolated frames at ~60fps. -Unlike gesture-pinch which moves fingers linearly to zoom, this orbits fingers in an arc to change orientation. -Use when you need to rotate a map, image picker, or any rotateable UI element. Returns { rotated: true, timestampMs }. Fails if the simulator server is not running for the given UDID.`, + description: `Two-finger rotation: fingers placed opposite each other at a fixed radius from center, swept from startAngle to endAngle degrees. +All positions and radius are normalized 0.0–1.0 (fractions of the screen, not pixels). +endAngle > startAngle = clockwise. Typical 90° clockwise turn: radius 0.15, startAngle 0, endAngle 90. +Unlike gesture-pinch (which moves fingers linearly to zoom), this orbits fingers in an arc to change content orientation. +Use to rotate a map, image picker, or any rotatable UI element. Returns { rotated, timestampMs }. Fails if the target device is not booted.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/gesture-swipe.ts b/packages/tool-server/src/tools/interactions/gesture-swipe.ts index 79de97bc..937d06f7 100644 --- a/packages/tool-server/src/tools/interactions/gesture-swipe.ts +++ b/packages/tool-server/src/tools/interactions/gesture-swipe.ts @@ -6,11 +6,7 @@ import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), fromX: z.number().describe("Start x: normalized 0.0–1.0 (not pixels; same as tap)"), fromY: z.number().describe("Start y: normalized 0.0–1.0 (not pixels; same as tap)"), toX: z.number().describe("End x: normalized 0.0–1.0 (not pixels; same as tap)"), @@ -26,11 +22,10 @@ export const gestureSwipeTool: ToolDefinition< { swiped: boolean; timestampMs: number } > = { id: "gesture-swipe", - description: `Execute a smooth swipe gesture between two points on iOS or Android. All from/to positions are normalized 0.0–1.0 (fractions of screen width/height, not pixels), same as gesture-tap and simulator-server touch. -Generates interpolated Move events for a natural feel (~60fps). -Swipe up (fromY > toY) to scroll content down. -Swipe down (fromY < toY) to scroll content up. -Use when you need to scroll a list, dismiss a modal, or navigate between pages. Returns { swiped: true, timestampMs }. Fails if the simulator server cannot start.`, + description: `Smooth swipe between two normalized points (0.0–1.0 fractions of screen width/height, not pixels). +Use to scroll a list, dismiss a modal, or navigate between pages. +Swipe up (fromY > toY) scrolls content down; swipe down (fromY < toY) scrolls content up. +Returns { swiped, timestampMs }. Fails if the target device is not booted.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/gesture-tap.ts b/packages/tool-server/src/tools/interactions/gesture-tap.ts index 7bb98814..8cb6a433 100644 --- a/packages/tool-server/src/tools/interactions/gesture-tap.ts +++ b/packages/tool-server/src/tools/interactions/gesture-tap.ts @@ -6,11 +6,7 @@ import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), x: z.number().describe("Normalized horizontal position 0.0–1.0 (left=0, right=1), not pixels"), y: z.number().describe("Normalized vertical position 0.0–1.0 (top=0, bottom=1), not pixels"), }); @@ -20,10 +16,10 @@ export const gestureTapTool: ToolDefinition< { tapped: boolean; timestampMs: number } > = { id: "gesture-tap", - description: `Press the screen at normalized coordinates on iOS or Android. x and y are fractions of screen width and height in 0.0–1.0 (not pixels), matching simulator-server touch input. -Sends a Down event followed by an Up event at the same point. -Use when you need to tap a button, link, or any tappable element. Returns { tapped: true, timestampMs }. Fails if the simulator server cannot start for the given udid (e.g. device not booted). -Before tapping, determine coordinates with a discovery tool: \`describe\`, \`debugger-component-tree\`, or \`native-describe-screen\` (iOS only). More in the \`argent-simulator-interact\` skill.`, + description: `Tap the screen at normalized coordinates. x and y are fractions of screen width/height in 0.0–1.0 (not pixels). +Use for any tappable element (buttons, links, cells). Sends a Down followed by an Up at the same point. +Before tapping, determine coordinates with a discovery tool (\`describe\`, \`debugger-component-tree\`, or \`native-describe-screen\`) — never eyeball them from a screenshot. +Returns { tapped, timestampMs }. Fails if the target device is not booted.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/keyboard.ts b/packages/tool-server/src/tools/interactions/keyboard.ts index 99b4035e..e4d75c54 100644 --- a/packages/tool-server/src/tools/interactions/keyboard.ts +++ b/packages/tool-server/src/tools/interactions/keyboard.ts @@ -149,7 +149,7 @@ const zodSchema = z.object({ .string() .optional() .describe( - "Text to type character by character via USB HID keycodes through simulator-server. Handles uppercase and common punctuation. Use when paste is unreliable." + "Text to type character by character. Handles uppercase and common punctuation. Use when paste is unreliable or unsupported by the focused field." ), key: z .string() @@ -165,13 +165,11 @@ export const keyboardTool: ToolDefinition< { typed: string; keys: number } > = { id: "keyboard", - description: `Type text or press special keys on iOS or Android using keyboard events. -Uses USB HID keycodes routed through simulator-server; the binary maps them to each platform's native key events internally. -Use when you need to enter text or trigger a named key such as enter, escape, or arrow keys. -Returns { typed: string, keys: number }. Fails on unsupported key names or if the simulator server cannot start. -- text: types a string character by character (supports uppercase, digits, common punctuation) -- key: presses a single named key (enter, escape, backspace, tab, arrow-up/down/left/right, f1–f12) -Provide text, key, or both.`, + description: `Type text or press a named key on the focused input. +Use when you need to enter text or trigger a named key such as enter, escape, or an arrow. +- text: types a string character by character (supports uppercase, digits, common punctuation). +- key: presses one named key (enter, escape, backspace, tab, space, arrow-up/down/left/right, f1–f12). +Provide text, key, or both. Returns { typed, keys }. Fails on unsupported key names.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, diff --git a/packages/tool-server/src/tools/interactions/run-sequence.ts b/packages/tool-server/src/tools/interactions/run-sequence.ts index bc416a52..caa876d4 100644 --- a/packages/tool-server/src/tools/interactions/run-sequence.ts +++ b/packages/tool-server/src/tools/interactions/run-sequence.ts @@ -3,8 +3,6 @@ import type { Registry, ToolDefinition } from "@argent/registry"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -// Unified tool names — simulator-server dispatches iOS vs Android internally, -// so every tool below works on both platforms with a consistent shape. const ALLOWED_TOOLS = new Set([ "gesture-tap", "gesture-swipe", @@ -20,7 +18,7 @@ const zodSchema = z.object({ udid: z .string() .describe( - "Device id shared across all steps. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." + "Target device id from `list-devices`, shared across all steps (iOS UDID or Android serial)." ), steps: z .array( @@ -56,14 +54,12 @@ export function createRunSequenceTool( ): ToolDefinition, RunSequenceResult> { return { id: "run-sequence", - description: `Execute multiple interaction steps in a single call, on iOS or Android. -Use when you need sequential actions and do NOT need to observe the screen between them -(e.g. scrolling multiple times, typing then pressing enter, rotating back and forth). + description: `Execute multiple interaction steps in a single call. +Use when you need sequential actions and do NOT need to observe the screen between them (e.g. scrolling multiple times, typing then pressing enter, rotating back and forth). Returns { completed, total, steps }. Stops on the first error and returns partial results. No screenshot is captured automatically — call \`screenshot\` separately after the sequence if needed. -ONLY use this when every step is known in advance. If any step depends on the result of a previous one -(e.g. tapping a menu item that only appears after a prior tap), use individual tool calls instead. +ONLY use this when every step is known in advance. If any step depends on the result of a previous one (e.g. tapping a menu item that only appears after a prior tap), use individual tool calls instead. Allowed tools and their args (udid is auto-injected — do NOT include it in args): diff --git a/packages/tool-server/src/tools/interactions/screenshot.ts b/packages/tool-server/src/tools/interactions/screenshot.ts index bd5e4911..5a5ba95f 100644 --- a/packages/tool-server/src/tools/interactions/screenshot.ts +++ b/packages/tool-server/src/tools/interactions/screenshot.ts @@ -4,11 +4,7 @@ import type { SimulatorServerApi } from "../../blueprints/simulator-server"; import { httpScreenshot } from "../../utils/simulator-client"; const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), rotation: z .enum(["Portrait", "LandscapeLeft", "LandscapeRight", "PortraitUpsideDown"]) .optional() @@ -28,9 +24,8 @@ export const screenshotTool: ToolDefinition< { url: string; path: string } > = { id: "screenshot", - description: `Capture a screenshot of the device screen on iOS or Android. Returns { url, path }; the MCP adapter renders it as a visible image. -Use when you need a baseline before an interaction or to inspect the current screen after a delay. -Both platforms route through simulator-server which serves the PNG over HTTP. Fails if the simulator server cannot start or the screenshot request times out.`, + description: `Capture a screenshot of the current device screen. Returns { url, path } and the MCP adapter renders it as a visible image. +Use for a baseline before an interaction or to inspect the current screen after a delay. Fails if the target device is not booted or the screenshot request times out.`, zodSchema, outputHint: "image", services: (params) => ({ diff --git a/packages/tool-server/src/tools/simulator/launch-app.ts b/packages/tool-server/src/tools/simulator/launch-app.ts index 7110db5d..9620abb1 100644 --- a/packages/tool-server/src/tools/simulator/launch-app.ts +++ b/packages/tool-server/src/tools/simulator/launch-app.ts @@ -10,22 +10,17 @@ import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() .describe( - "App identifier. iOS: bundle id (e.g. com.apple.MobileSMS). Android: package name (e.g. com.android.settings) — the `applicationId` from build.gradle." + "App identifier. iOS: bundle id (e.g. com.apple.MobileSMS). Android: package name from build.gradle `applicationId` (e.g. com.android.settings)." ), activity: z .string() .optional() .describe( - "Android-only: optional fully-qualified Activity name (e.g. `.MainActivity` or `com.example/com.example.MainActivity`). " + - "If omitted on Android, the default launcher activity is used via `monkey`. Ignored on iOS." + "Android-only: fully-qualified Activity name (e.g. `.MainActivity` or `com.example/com.example.MainActivity`). If omitted on Android, the app's default launcher activity is used. Ignored on iOS." ), }); @@ -34,12 +29,9 @@ export const launchAppTool: ToolDefinition< { launched: boolean; bundleId: string } > = { id: "launch-app", - description: `Open an app by bundle id (iOS) or package name (Android). Prefer this over tapping home-screen / launcher icons. - -iOS: uses \`xcrun simctl launch\`; prepares native-devtools launch injection before the app starts. -Android: uses \`am start -n /\` when \`activity\` is provided, otherwise sends a LAUNCHER intent via \`monkey\`. - -Returns { launched, bundleId }. Fails if the app is not installed on the device. + description: `Open an app by its bundle id (iOS) or package name (Android). +Use when starting any app — prefer this over tapping home-screen / launcher icons. Also prepares the native-devtools injection on iOS before the app starts. +Returns { launched, bundleId }. Fails if the app is not installed on the target device. Common iOS bundle ids: com.apple.MobileSMS, com.apple.mobilesafari, com.apple.Preferences, com.apple.Maps, com.apple.camera, com.apple.Photos, com.apple.mobilemail, com.apple.mobilenotes, com.apple.MobileAddressBook Common Android packages: com.android.settings, com.android.chrome, com.google.android.apps.maps, com.google.android.gm, com.android.vending, com.google.android.dialer, com.google.android.apps.messaging`, diff --git a/packages/tool-server/src/tools/simulator/open-url.ts b/packages/tool-server/src/tools/simulator/open-url.ts index fe08d66e..b8909761 100644 --- a/packages/tool-server/src/tools/simulator/open-url.ts +++ b/packages/tool-server/src/tools/simulator/open-url.ts @@ -8,15 +8,11 @@ import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), url: z .string() .describe( - "URL or scheme to open (e.g. https://example.com, messages://, tel://555, geo:37.0,-122.0)." + "URL or scheme to open (e.g. https://example.com, messages://, tel:555, geo:37.0,-122.0)." ), }); @@ -25,10 +21,9 @@ export const openUrlTool: ToolDefinition< { opened: boolean; url: string } > = { id: "open-url", - description: `Open a URL or URL scheme on iOS or Android. -iOS: \`xcrun simctl openurl\`. -Android: \`am start -a android.intent.action.VIEW -d \`. -Common schemes work on both: https://, tel:, mailto:. iOS also: messages://, settings://, maps://. Android: geo:, plus any app-specific deep link. + description: `Open a URL or URL scheme on the device. +Use to navigate to a web page or deep-link into an app. +Cross-platform schemes: https://, tel:, mailto:. iOS also: messages://, settings://, maps://. Android also: geo:, plus any app-specific deep link. Returns { opened, url }. Fails if no app is registered to handle the URI.`, zodSchema, services: () => ({}), diff --git a/packages/tool-server/src/tools/simulator/reinstall-app.ts b/packages/tool-server/src/tools/simulator/reinstall-app.ts index 629afcbd..77bc2a13 100644 --- a/packages/tool-server/src/tools/simulator/reinstall-app.ts +++ b/packages/tool-server/src/tools/simulator/reinstall-app.ts @@ -9,33 +9,25 @@ import { runAdb } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() .describe( - "iOS: bundle id to uninstall before installing. Android: package name (used only for clarity in the return payload; `adb install -r` identifies the app from the APK itself). Must match the app at appPath." + "App identifier that matches the bundle at `appPath`. iOS: bundle id (used to uninstall first so data is cleared). Android: package name (used only in the return payload — the install identifies the app from the APK)." ), appPath: z .string() .describe( - "Absolute path to the app bundle. iOS: `.app` directory (e.g. ./build/Build/Products/Debug-iphonesimulator/MyApp.app). Android: `.apk` file (e.g. android/app/build/outputs/apk/debug/app-debug.apk)." + "Path to the app bundle. iOS: `.app` directory (e.g. ./build/.../MyApp.app). Android: `.apk` file (e.g. android/app/build/outputs/apk/debug/app-debug.apk). Relative paths are resolved from the current working directory." ), grantPermissions: z .boolean() .optional() - .describe( - "Android-only: auto-grant all runtime permissions on install (`adb install -g`). Ignored on iOS." - ), + .describe("Android-only: auto-grant all runtime permissions on install. Ignored on iOS."), allowDowngrade: z .boolean() .optional() - .describe( - "Android-only: allow installing a lower versionCode (`adb install -d`). Ignored on iOS." - ), + .describe("Android-only: allow installing a lower versionCode. Ignored on iOS."), }); export const reinstallAppTool: ToolDefinition< @@ -44,9 +36,8 @@ export const reinstallAppTool: ToolDefinition< > = { id: "reinstall-app", description: `Install or reinstall an app on the device. -iOS: uninstalls the existing bundleId (if present), then \`xcrun simctl install\` from a .app path. Clears app data. -Android: \`adb install -r\` from an APK path. \`-r\` preserves data across installs; pass \`grantPermissions: true\` for \`-g\`. -Returns { reinstalled, bundleId }. Fails if the path does not exist or the package is malformed.`, +Use for a full reinstall after rebuilding, or to clear app data (iOS clears data on every reinstall; Android preserves data unless the caller wipes it). +Returns { reinstalled, bundleId }. Fails if the app path does not exist or the package does not match the platform (.app for iOS, .apk for Android).`, zodSchema, services: () => ({}), async execute(_services, params) { diff --git a/packages/tool-server/src/tools/simulator/restart-app.ts b/packages/tool-server/src/tools/simulator/restart-app.ts index 01272f8e..33f470a9 100644 --- a/packages/tool-server/src/tools/simulator/restart-app.ts +++ b/packages/tool-server/src/tools/simulator/restart-app.ts @@ -10,11 +10,7 @@ import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. For iOS: simulator UDID (UUID shape). For Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z.string().describe("App identifier. iOS: bundle id. Android: package name."), }); @@ -23,10 +19,9 @@ export const restartAppTool: ToolDefinition< { restarted: boolean; bundleId: string } > = { id: "restart-app", - description: `Restart an app by terminating then relaunching it. -iOS: \`xcrun simctl terminate\` + launch; refreshes native-devtools injection. -Android: \`am force-stop\` + \`monkey\` launcher intent. -Use when you need a clean in-memory state without a full reinstall. Returns { restarted, bundleId }. Fails if the app is not installed.`, + description: `Terminate then relaunch an app by bundle id / package name. +Use when you need a clean in-memory state without a full reinstall. Also refreshes the native-devtools injection on iOS before the relaunch. +Returns { restarted, bundleId }. Fails if the app is not installed.`, zodSchema, services: (params): Record => detectPlatform(params.udid) === "ios" diff --git a/packages/tool-server/src/tools/simulator/rotate.ts b/packages/tool-server/src/tools/simulator/rotate.ts index 0154ed25..ebf08bd3 100644 --- a/packages/tool-server/src/tools/simulator/rotate.ts +++ b/packages/tool-server/src/tools/simulator/rotate.ts @@ -4,11 +4,7 @@ import type { SimulatorServerApi } from "../../blueprints/simulator-server"; import { sendCommand } from "../../utils/simulator-client"; const zodSchema = z.object({ - udid: z - .string() - .describe( - "Device id. iOS: simulator UDID (UUID shape). Android: adb serial (e.g. `emulator-5554`)." - ), + udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), orientation: z .enum(["Portrait", "LandscapeLeft", "LandscapeRight", "PortraitUpsideDown"]) .describe("Target orientation"), @@ -16,7 +12,9 @@ const zodSchema = z.object({ export const rotateTool: ToolDefinition, { orientation: string }> = { id: "rotate", - description: `Set the device orientation to Portrait, LandscapeLeft, LandscapeRight, or PortraitUpsideDown. Works on iOS and Android via simulator-server. Re-run \`describe\` afterwards — frame coordinates change. Returns { orientation }. Fails if the simulator server cannot start.`, + description: `Set the device orientation to Portrait, LandscapeLeft, LandscapeRight, or PortraitUpsideDown. +Use to test layout in a different orientation. Re-run \`describe\` afterwards — frame coordinates change with the orientation. +Returns { orientation }. Fails if the target device is not booted.`, zodSchema, services: (params) => ({ simulatorServer: `SimulatorServer:${params.udid}`, From c081fe2248a094e071f8ef4f1280a7464eacf9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 13:36:40 +0200 Subject: [PATCH 006/149] docs(skills): point to list-devices / boot-device and strip impl-detail leaks Skill files now direct the agent to `list-devices` + `boot-device` (one flow for both platforms) instead of the removed platform-specific `list- simulators` / `boot-simulator` / `android-list-emulators` / `android-boot- emulator` pairs. Also trims protocol-layer explanations from skill surfaces where they don't change the caller's behavior. --- packages/skills/rules/argent.md | 39 ++++++++----------- .../argent-android-emulator-interact/SKILL.md | 23 +++++++---- .../argent-android-emulator-setup/SKILL.md | 10 ++--- .../skills/argent-metro-debugger/SKILL.md | 2 +- .../argent-react-native-app-workflow/SKILL.md | 33 ++++++++-------- .../skills/argent-simulator-interact/SKILL.md | 13 +++---- .../skills/argent-simulator-setup/SKILL.md | 4 +- .../skills/argent-test-ui-flow/SKILL.md | 8 ++-- 8 files changed, 66 insertions(+), 66 deletions(-) diff --git a/packages/skills/rules/argent.md b/packages/skills/rules/argent.md index 448755d0..de23860b 100644 --- a/packages/skills/rules/argent.md +++ b/packages/skills/rules/argent.md @@ -18,39 +18,32 @@ Use cases: -Interaction tools are unified across iOS and Android. Pass the device id as `udid` and the tool-server dispatches based on its shape. +Interaction tools are unified across iOS and Android. Pass the device id as `udid` and the tool-server selects the right platform automatically. -- **iOS udid**: UUID shape — `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (from `list-simulators`). Or iOS 17+ short form `XXXXXXXX-XXXXXXXXXXXXXXXX`. -- **Android udid**: adb serial (from `android-list-emulators`) — `emulator-5554`, `R5CT12345678`, `192.168.1.7:5555`, etc. +Get device ids from `list-devices`, which returns iOS simulators and Android devices/emulators tagged with a `platform` discriminator: -Unified tools (pass `udid`): `gesture-tap`, `gesture-swipe`, `gesture-custom`, `gesture-pinch`, `gesture-rotate`, `button`, `keyboard`, `rotate`, `screenshot`, `describe`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `run-sequence`. +- **iOS udid**: UUID shape — `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX`. Or iOS 17+ short form `XXXXXXXX-XXXXXXXXXXXXXXXX`. +- **Android udid**: adb serial — `emulator-5554`, `R5CT12345678`, `192.168.1.7:5555`, etc. -Navigation + gestures (including multi-touch pinch/rotate/custom) route through `simulator-server`, which the binary dispatches to iOS or Android internally. `describe` uses AXRuntime → native-devtools fallback on iOS and `uiautomator dump` on Android; app-lifecycle tools (`launch-app` / `restart-app` / `reinstall-app` / `open-url`) use `xcrun simctl` on iOS and `adb` / `am` / `monkey` on Android. +Unified tools (pass `udid` from `list-devices`): `gesture-tap`, `gesture-swipe`, `gesture-custom`, `gesture-pinch`, `gesture-rotate`, `button`, `keyboard`, `rotate`, `screenshot`, `describe`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `run-sequence`. + +Cross-platform lifecycle tools: `list-devices` (lists both platforms + available Android AVDs), `boot-device` (pass `udid` for iOS or `avdName` for Android). Platform-specific tools (no unified counterpart): -- **iOS**: `list-simulators`, `boot-simulator`, `stop-simulator-server`, `stop-all-simulator-servers`, native-devtools suite, iOS Instruments profiler, `paste`. -- **Android**: `android-list-emulators`, `android-boot-emulator`, `android-stop-app`, `android-logcat`. +- **iOS**: `stop-simulator-server`, `stop-all-simulator-servers`, native-devtools suite, iOS Instruments profiler, `paste`. +- **Android**: `android-stop-app`, `android-logcat`. -If the project only has an `android/` directory (no `ios/`), start from `android-list-emulators`; if only iOS, start from `list-simulators`. For hybrid projects, ask the user which platform to target. Never pass an iOS UDID to an Android-only tool or vice versa. +If the project only has an `android/` directory (no `ios/`), pick an Android target from `list-devices`; if only iOS, pick an iOS target. For hybrid projects, ask the user which platform to target. **Never** derive tap coordinates from a screenshot Before **every** tap, you MUST call a discovery tool and extract coordinates from the result. This is not optional. Preferred tools are, in order: -**iOS:** - -- `describe` - native app-level components and safely targetable foreground apps -- `native-describe-screen` - accessibility screen description via injected native devtools -- `debugger-component-tree` - react-native specific components - -`native-user-interactable-view-at-point` / `native-view-at-point` are follow-up diagnostics once you already have a candidate point. - -**Android:** - -- `android-describe-screen` - uiautomator-based UI tree (same shape as iOS `describe`) -- `debugger-component-tree` - react-native specific components (requires `adb reverse tcp:8081 tcp:8081` so Metro is reachable) +- `describe` - UI hierarchy with roles, labels, and frame coordinates (works on iOS and Android) +- `debugger-component-tree` - React Native specific component tree, when a Metro debugger connection is available +- `native-describe-screen` / `native-user-interactable-view-at-point` / `native-view-at-point` - iOS-only diagnostics once you already have a candidate point Whenever something changed YOU MUST first call the platform's describe tool, or another appropriate discovery tool so you do not hallucinate element positions. Do not guess coordinates if you can use a discovery tool. Do not tap if you have not called a discovery tool in the current step. Screenshots alone are never sufficient for coordinates. @@ -71,7 +64,7 @@ Before starting to interact with the app, read `argent-simulator-interact` (iOS) - Before calling any gesture tool for the first time, use ToolSearch to load its schema. - Interaction tools (`gesture-tap`, `gesture-swipe`, `button`, `keyboard`, `rotate`, `launch-app`, `restart-app`, `open-url`, `describe`, `run-sequence`) return a screenshot automatically. Call `screenshot` separately only for a baseline before any action or after a delay. - Always open apps with `launch-app` / `open-url` — never tap home-screen / launcher icons. -- Use `run-sequence` when performing multiple sequential actions where you don't need to observe the screen between steps. Works on both iOS and Android; iOS-only step types (gesture-pinch / gesture-rotate / gesture-custom) throw if the run-sequence udid is Android. +- Use `run-sequence` when performing multiple sequential actions where you don't need to observe the screen between steps. Works on both iOS and Android. - When the session ends or the user says they are done: - iOS — call `stop-all-simulator-servers`. - Android — shut down the emulator from its own UI or via `adb -s emu kill` if the user wants it off. Argent does not keep persistent per-emulator state, so no server-side teardown is required. @@ -96,11 +89,11 @@ procedure and edge-case handling for each workflow. iOS SIMULATOR SETUP Skill: `argent-simulator-setup` -When: Beginning a task that involves the iOS simulator, no simulator booted yet, need UDID or simulator-server. +When: Beginning a task that involves the iOS simulator, no simulator booted yet, or you need a simulator UDID. ANDROID EMULATOR SETUP Skill: `argent-android-emulator-setup` -When: Beginning a task that involves the Android emulator, no emulator running yet, need a serial, or about to install an APK. +When: Beginning a task that involves the Android emulator, no emulator running yet, need an adb serial, or about to install an APK. iOS TAPPING, SWIPING, TYPING, GESTURES, SCREENSHOTS, SCROLLING Skill: `argent-simulator-interact` diff --git a/packages/skills/skills/argent-android-emulator-interact/SKILL.md b/packages/skills/skills/argent-android-emulator-interact/SKILL.md index f0107d1f..6cd3241b 100644 --- a/packages/skills/skills/argent-android-emulator-interact/SKILL.md +++ b/packages/skills/skills/argent-android-emulator-interact/SKILL.md @@ -29,16 +29,23 @@ Use these tools directly — no `android-*` prefix: For tool-by-tool usage see `argent-simulator-interact`. +## Device lifecycle (cross-platform) + +Use the unified tools — they work for both iOS and Android via the id shape: + +| Tool | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------------- | +| `list-devices` | List iOS simulators + Android devices/emulators with `platform` tags, plus available Android AVDs | +| `boot-device` | iOS: pass `udid` to boot a simulator. Android: pass `avdName` to launch an emulator (cold boot default) | + ## Android-only tools -These have no iOS equivalent and keep their `android-` prefix: +These have no cross-platform counterpart: -| Tool | Purpose | -| ------------------------ | ---------------------------------------------------------------------------------------- | -| `android-list-emulators` | List adb devices + available AVDs | -| `android-boot-emulator` | Boot an AVD by name (cold boot by default; 2–5 min; clean failure if it doesn't come up) | -| `android-stop-app` | `am force-stop` without relaunching | -| `android-logcat` | Recent log lines. Filter by `bundleId`, `priority` (V/D/I/W/E/F), `tag` | +| Tool | Purpose | +| ------------------ | ----------------------------------------------------------------------- | +| `android-stop-app` | Force-stop an app without relaunching | +| `android-logcat` | Recent log lines. Filter by `bundleId`, `priority` (V/D/I/W/E/F), `tag` | ## Platform detection @@ -48,7 +55,7 @@ The tool-server looks at the `udid` string: - `XXXXXXXX-XXXXXXXXXXXXXXXX` → iOS 17+ short form - Anything else (e.g. `emulator-5554`, `R5CT12345678`) → Android adb serial -Pass iOS UDIDs from `list-simulators` and Android serials from `android-list-emulators`. Do not pass them to the wrong platform — dispatch is automatic. +Always source device ids from `list-devices` — its output is already tagged with `platform`. ## Android-specific gotchas diff --git a/packages/skills/skills/argent-android-emulator-setup/SKILL.md b/packages/skills/skills/argent-android-emulator-setup/SKILL.md index 31bb10b0..ad6808b9 100644 --- a/packages/skills/skills/argent-android-emulator-setup/SKILL.md +++ b/packages/skills/skills/argent-android-emulator-setup/SKILL.md @@ -6,24 +6,24 @@ description: Set up and connect to an Android emulator using argent MCP tools. U ## 1. Prerequisites - **Android SDK Platform Tools** on PATH — provides `adb`. -- **Android Emulator** on PATH — needed to boot AVDs via `android-boot-emulator`. If you will only use an already-running emulator or a physical device, adb alone is sufficient. +- **Android Emulator** on PATH — needed to boot AVDs. If you will only use an already-running emulator or a physical device, adb alone is sufficient. - An AVD created via Android Studio or `avdmanager create avd`. Verify with `adb version` and `emulator -list-avds`. ## 2. Setup -1. **Find a ready device** — call `android-list-emulators`. Ready devices have `state: "device"` and come first. Pick the first serial (e.g. `emulator-5554`) unless the user specified one. -2. **Boot if needed** — if nothing is ready, call `android-boot-emulator` with the AVD `name` from the same call's `avds` list. The tool cold-boots by default (reliability over speed — 2–5 min typical) and returns a clean `serial`. On any stage failure it kills the emulator process it started, so your next call begins from a clean state. +1. **Find a ready device** — call `list-devices`. Filter for entries with `platform: "android"`. Ready devices (`state: "device"`) come first. Pick the first `serial` (e.g. `emulator-5554`) unless the user specified one. +2. **Boot if needed** — if nothing Android is ready, call `boot-device` with `avdName: ` from the same call's `avds` list. Cold boot by default (reliability over speed — 2–5 min typical). On any stage failure the tool kills the emulator process it started so your next call starts from a clean state. 3. **Metro (for React Native)** — once a device is up, run `adb -s reverse tcp:8081 tcp:8081` so the device can reach Metro on your host. Repeat if the device restarts. See the `argent-metro-debugger` skill. ## 3. Using the device -Pass the Android serial as `udid` to the unified interaction tools — `tap`, `swipe`, `describe`, `screenshot`, `launch-app`, `keyboard`, etc. The tool-server auto-dispatches based on the id shape. See `argent-simulator-interact` (the base interaction skill, platform-neutral) and `argent-android-emulator-interact` (Android-specific gotchas). +Pass the Android serial as `udid` to the unified interaction tools — `gesture-tap`, `gesture-swipe`, `describe`, `screenshot`, `launch-app`, `keyboard`, etc. Dispatch is automatic based on the id shape. See `argent-simulator-interact` (platform-neutral interaction) and `argent-android-emulator-interact` (Android-specific gotchas). ## 4. Notes - Serials are the adb device id. iOS UDIDs and Android serials are not interchangeable, but you do NOT need to tell the tools which platform — dispatch is automatic. -- Android does not have the iOS native-devtools dylib equivalent. `describe` uses `uiautomator` on Android, which is shallower than the iOS AX tree but covers most tap-target discovery. +- `describe` on Android returns a shallower tree than iOS (no accessibility-service equivalent), but covers most tap-target discovery. - For first-launch permission prompts, pass `grantPermissions: true` to `reinstall-app`. - To kill the emulator when you're done, run `adb -s emu kill` from a shell. diff --git a/packages/skills/skills/argent-metro-debugger/SKILL.md b/packages/skills/skills/argent-metro-debugger/SKILL.md index d00c3e66..cf17f9d6 100644 --- a/packages/skills/skills/argent-metro-debugger/SKILL.md +++ b/packages/skills/skills/argent-metro-debugger/SKILL.md @@ -15,7 +15,7 @@ Android emulators and physical devices do not resolve the host's `localhost` by adb -s reverse tcp:8081 tcp:8081 ``` -`` comes from `android-list-emulators`. Once reversed, the app on the device connects to Metro just like an iOS simulator does, and all `debugger-*` / `network-*` / `react-profiler-*` tools work unchanged. If the device restarts or adb drops, re-run the command. A failing Metro connection on Android almost always means `adb reverse` has not been done or has been lost. +`` is the Android `serial` from `list-devices`. Once reversed, the app on the device connects to Metro just like an iOS simulator does, and all `debugger-*` / `network-*` / `react-profiler-*` tools work unchanged. If the device restarts or adb drops, re-run the command. A failing Metro connection on Android almost always means `adb reverse` has not been done or has been lost. ## 2. Tool Overview diff --git a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md index 3e216cdc..517d8c23 100644 --- a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md +++ b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md @@ -55,7 +55,7 @@ Optional: specify device or simulator, e.g. `npx react-native run-ios --simulato - [ ] Metro is already running and shows "ready" - [ ] Command run from project root -- [ ] If simulator not booted: use the `boot-simulator` tool with proper UDID. Refer to the `argent-simulator-setup` skill. +- [ ] If simulator not booted: use `boot-device` with the iOS `udid`. Refer to the `argent-simulator-setup` skill. ### 1.4 Run the Android App @@ -73,7 +73,7 @@ cd android && ./gradlew :app:assembleDebug && cd .. Then, using the argent MCP tools (note: the interaction tools are unified — pass the Android serial as `udid`): -1. `android-list-emulators` — pick a ready serial (or boot one via `android-boot-emulator`). See the `argent-android-emulator-setup` skill. +1. `list-devices` — pick a ready Android serial (or boot one via `boot-device` with `avdName`). See the `argent-android-emulator-setup` skill. 2. `reinstall-app` with `udid=`, `bundleId=`, absolute `appPath=`. Set `grantPermissions: true` to skip runtime permission prompts on first launch. 3. `launch-app` with `udid=` and `bundleId=` (from `android/app/build.gradle` — the environment inspector surfaces this as `android_application_id`). 4. **Metro reachability**: run `adb -s reverse tcp:8081 tcp:8081` so the app on the device can reach Metro on your host. Repeat if the device restarts or adb drops. See the `argent-metro-debugger` skill. @@ -85,7 +85,7 @@ Alternative one-shot: `npx react-native run-android` builds, installs, and launc - [ ] Metro is running - [ ] `adb -s reverse tcp:8081 tcp:8081` done - [ ] Command run from project root (or `./gradlew` from `android/`) -- [ ] If emulator not booted: `android-boot-emulator` first +- [ ] If emulator not booted: call `boot-device` with an `avdName` from `list-devices`.avds --- @@ -161,18 +161,19 @@ Once you discover the correct build/run workflow for a project, **save it to pro | App needs reinstalling from .app path | Use `reinstall-app` tool with UDID, bundle ID, and .app path. | | Persistent native build errors | Full clean + reinstall (step 2 above). | -### 3.5 iOS Simulator Control +### 3.5 Device Control -| Action | Tool / Command | -| -------------------------- | -------------------------------------------------- | -| List devices | `list-simulators` tool | -| Boot a simulator | `boot-simulator` tool (pass UDID) | -| Launch an app | `launch-app` tool (pass UDID + bundle ID) | -| Restart an app | `restart-app` tool (pass UDID + bundle ID) | -| Open a URL / deep link | `open-url` tool (pass UDID + URL) | -| Rotate simulator | `rotate` tool | -| Stop simulator server | `stop-simulator-server` tool (for a specific UDID) | -| Stop all simulator servers | `stop-all-simulator-servers` tool | +| Action | Tool / Command | +| -------------------------- | -------------------------------------------------------------- | +| List devices | `list-devices` tool (iOS + Android) | +| Boot an iOS simulator | `boot-device` tool with `udid` | +| Boot an Android emulator | `boot-device` tool with `avdName` | +| Launch an app | `launch-app` tool (pass device id + bundle id / package name) | +| Restart an app | `restart-app` tool (pass device id + bundle id / package name) | +| Open a URL / deep link | `open-url` tool (pass device id + URL) | +| Rotate device | `rotate` tool | +| Stop simulator server | `stop-simulator-server` tool (iOS only — for a specific UDID) | +| Stop all simulator servers | `stop-all-simulator-servers` tool (iOS only) | For full simulator setup workflow, refer to the `argent-simulator-setup` skill. @@ -240,8 +241,8 @@ If the user's intent is ambiguous (run existing tests, write new tests, or find | Start Metro | `npx react-native start` | | Start Metro (reset cache) | `npx react-native start --reset-cache` | | Run iOS app | `npx react-native run-ios` | -| List simulators | `list-simulators` tool | -| Boot simulator | `boot-simulator` tool | +| List devices | `list-devices` tool (iOS + Android) | +| Boot a device | `boot-device` tool (pass `udid` for iOS or `avdName` for Android) | | Take screenshot | `screenshot` tool | | Describe screen (a11y tree) | `describe` tool for normal app screens and in-app modals; use `screenshot` only when permission/system overlays are not exposed reliably | | Read JS console logs | `debugger-log-registry` tool | diff --git a/packages/skills/skills/argent-simulator-interact/SKILL.md b/packages/skills/skills/argent-simulator-interact/SKILL.md index a0dd5229..7ea5c185 100644 --- a/packages/skills/skills/argent-simulator-interact/SKILL.md +++ b/packages/skills/skills/argent-simulator-interact/SKILL.md @@ -13,9 +13,7 @@ For Android-specific caveats (gestures that only exist on iOS, Android-only butt If you delegate simulator tasks to sub-agents, make sure they have MCP permissions. -iOS: use `list-simulators`. **Pick the first result** if not specified by the user — booted iPhones are listed first. If none are booted, use `boot-simulator` first. - -Android: use `android-list-emulators`. Pick the first `state: "device"`. If none are booted, use `android-boot-emulator` first. See `argent-android-emulator-setup`. +Use `list-devices` to get a target id. Results are tagged with `platform` (`ios` or `android`); booted/ready devices come first. Pick the first entry that matches the platform you need — if none are ready, call `boot-device` with `udid` (iOS) or `avdName` (Android). See `argent-simulator-setup` / `argent-android-emulator-setup` for full setup flow. **Load tool schemas before first use.** Gesture tools (`gesture-tap`, `gesture-swipe`, `gesture-pinch`, `gesture-rotate`, `gesture-custom`) may be deferred — their parameter schemas are not loaded until fetched. Always use ToolSearch to load the schemas of all gesture tools you plan to use **before** calling any of them. If you skip this step, parameters may be coerced to strings instead of numbers, causing validation errors. @@ -204,10 +202,11 @@ Screenshots are downscaled by default (30% of original resolution) to reduce con ### Troubleshooting -| Problem | Solution | -| -------------------- | ------------------------------------------------------------- | -| Screenshot times out | Restart the simulator-server via `stop-simulator-server` tool | -| No booted simulator | Run `boot-simulator` first. | +| Problem | Solution | +| ----------------------- | ------------------------------------------------------------- | +| Screenshot times out | Restart the simulator-server via `stop-simulator-server` tool | +| No booted iOS simulator | Call `boot-device` with the iOS `udid` | +| No ready Android device | Call `boot-device` with `avdName` | --- diff --git a/packages/skills/skills/argent-simulator-setup/SKILL.md b/packages/skills/skills/argent-simulator-setup/SKILL.md index e4fdafa6..92e4e6bf 100644 --- a/packages/skills/skills/argent-simulator-setup/SKILL.md +++ b/packages/skills/skills/argent-simulator-setup/SKILL.md @@ -8,8 +8,8 @@ description: Set up and connect to an iOS simulator using argent MCP tools. Use If you delegate simulator tasks to sub-agents, make sure they have MCP permissions. 1. **Find a booted simulator** - Use `list-simulators`. Pick the first result — booted iPhones are listed first. - If none are booted, use `boot-simulator` with the desired UDID. + Use `list-devices`. Filter for entries with `platform: "ios"` — booted iPhones are listed first. + If none are booted, call `boot-device` with `udid: `. 2. **Verify connection** All interaction tools (`gesture-tap`, `gesture-swipe`, `gesture-custom`, etc.) auto-start the server if not already running. diff --git a/packages/skills/skills/argent-test-ui-flow/SKILL.md b/packages/skills/skills/argent-test-ui-flow/SKILL.md index 0035cb74..f10fbf21 100644 --- a/packages/skills/skills/argent-test-ui-flow/SKILL.md +++ b/packages/skills/skills/argent-test-ui-flow/SKILL.md @@ -9,10 +9,10 @@ The interaction tool names are identical on iOS and Android — `gesture-tap`, ` Get a `udid` via: -| Platform | Setup skill | Find devices with | -| -------- | ------------------------------- | ---------------------------------------------------------------- | -| iOS | `argent-simulator-setup` | `list-simulators` → `boot-simulator` if none booted | -| Android | `argent-android-emulator-setup` | `android-list-emulators` → `android-boot-emulator` if none ready | +| Platform | Setup skill | Find devices with | +| -------- | ------------------------------- | ----------------------------------------------------------- | +| iOS | `argent-simulator-setup` | `list-devices` → `boot-device` with `udid` if none booted | +| Android | `argent-android-emulator-setup` | `list-devices` → `boot-device` with `avdName` if none ready | ## 1. Workflow From caed2c309ff761d0f8e48bccc286010af74c14ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 16:51:19 +0200 Subject: [PATCH 007/149] refactor: list-based classifyDevice replaces shape heuristic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform detection now looks up the udid in the actual inventories from `xcrun simctl list` and `adb devices`. If the id lives in simctl's list it is iOS; if it lives in adb's list it is Android. The shape heuristic survives only as a last-resort fallback when both tools are unavailable (no Xcode AND no adb installed). This drops the 8-16 hex short form from the iOS shape pattern — that form is physical-device-only and routing it to simctl used to produce an opaque "Invalid device" error (review #8). The classifier is async. `list-devices` warms a per-udid cache so every subsequent tool call is O(1); the cache TTL is 30 s so short-lived changes on the host still propagate. Call sites that were in async context switch straight to `classifyDevice`. `launch-app` and `restart-app` move from the tool-object form to a factory (`createLaunchAppTool(registry)`) so the NativeDevtools service resolution can defer into `execute` and share the async classify call. The earlier iOS behavior — ensureEnvReady before `xcrun simctl launch`, refresh before relaunch — is unchanged and pinned by tests. --- .../src/blueprints/simulator-server.ts | 15 +- .../src/tools/devices/list-devices.ts | 66 ++------ .../src/tools/interactions/describe.ts | 17 ++- .../src/tools/simulator/launch-app.ts | 110 ++++++++------ .../src/tools/simulator/open-url.ts | 9 +- .../src/tools/simulator/reinstall-app.ts | 9 +- .../src/tools/simulator/restart-app.ts | 88 ++++++----- packages/tool-server/src/utils/ios-devices.ts | 48 ++++++ .../tool-server/src/utils/platform-detect.ts | 86 +++++++++-- .../tool-server/src/utils/setup-registry.ts | 8 +- .../tool-server/test/classify-device.test.ts | 141 ++++++++++++++++++ .../test/describe-android-dispatch.test.ts | 10 +- .../test/launch-app-dispatch.test.ts | 83 ++++++----- .../tool-server/test/platform-detect.test.ts | 41 ----- .../test/reinstall-app-dispatch.test.ts | 9 ++ .../test/restart-app-dispatch.test.ts | 48 +++--- .../test/simulator-server-blueprint.test.ts | 37 ++++- 17 files changed, 551 insertions(+), 274 deletions(-) create mode 100644 packages/tool-server/src/utils/ios-devices.ts create mode 100644 packages/tool-server/test/classify-device.test.ts delete mode 100644 packages/tool-server/test/platform-detect.test.ts diff --git a/packages/tool-server/src/blueprints/simulator-server.ts b/packages/tool-server/src/blueprints/simulator-server.ts index 50ad87ca..421214eb 100644 --- a/packages/tool-server/src/blueprints/simulator-server.ts +++ b/packages/tool-server/src/blueprints/simulator-server.ts @@ -8,7 +8,7 @@ import { } from "@argent/registry"; import { simulatorServerBinaryPath, simulatorServerBinaryDir } from "@argent/native-devtools-ios"; import { ensureAutomationEnabled } from "./ax-service"; -import { detectPlatform } from "../utils/platform-detect"; +import { classifyDevice } from "../utils/platform-detect"; export const SIMULATOR_SERVER_NAMESPACE = "SimulatorServer"; @@ -37,15 +37,17 @@ export interface SimulatorServerApi { * stdin MUST stay open — the server treats EOF on stdin as a shutdown signal. * `stdio: ["pipe", "pipe", "pipe"]` below provides that. */ -function spawnSimulatorServerProcess(udid: string): Promise<{ +function spawnSimulatorServerProcess( + udid: string, + platform: "ios" | "android" +): Promise<{ proc: ChildProcess; apiUrl: string; streamUrl: string; }> { const { BINARY_PATH, BINARY_DIR } = getPaths(); - const subcommand = detectPlatform(udid) === "android" ? "android" : "ios"; return new Promise((resolve, reject) => { - const args = [subcommand, "--id", udid]; + const args = [platform, "--id", udid]; const proc = spawn(BINARY_PATH, args, { cwd: BINARY_DIR, @@ -107,12 +109,13 @@ export const simulatorServerBlueprint: ServiceBlueprint {}); } - const { proc, apiUrl, streamUrl } = await spawnSimulatorServerProcess(udid); + const { proc, apiUrl, streamUrl } = await spawnSimulatorServerProcess(udid, platform); const events = new TypedEventEmitter(); diff --git a/packages/tool-server/src/tools/devices/list-devices.ts b/packages/tool-server/src/tools/devices/list-devices.ts index 6b3b603b..3c364179 100644 --- a/packages/tool-server/src/tools/devices/list-devices.ts +++ b/packages/tool-server/src/tools/devices/list-devices.ts @@ -1,18 +1,10 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import { listAndroidDevices, listAvds } from "../../utils/adb"; +import { listIosSimulators, type IosSimulator } from "../../utils/ios-devices"; +import { warmDeviceCache } from "../../utils/platform-detect"; -const execFileAsync = promisify(execFile); - -type IosDevice = { - platform: "ios"; - udid: string; - name: string; - state: string; - runtime: string; -}; +type IosDevice = IosSimulator & { platform: "ios" }; type AndroidDevice = { platform: "android"; @@ -29,45 +21,6 @@ type ListDevicesResult = { avds: Array<{ name: string }>; }; -interface SimctlDevice { - udid: string; - name: string; - state: string; - deviceTypeIdentifier: string; - isAvailable: boolean; -} - -interface SimctlOutput { - devices: Record; -} - -async function listIosSimulators(): Promise { - try { - const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"], { - timeout: 10_000, - }); - const data: SimctlOutput = JSON.parse(stdout); - const out: IosDevice[] = []; - for (const [runtimeId, devices] of Object.entries(data.devices)) { - if (!runtimeId.includes("iOS")) continue; - for (const d of devices) { - if (!d.isAvailable) continue; - out.push({ - platform: "ios", - udid: d.udid, - name: d.name, - state: d.state, - runtime: runtimeId, - }); - } - } - return out; - } catch { - // macOS without Xcode, or non-mac host — no iOS devices to report - return []; - } -} - function sortIos(a: IosDevice, b: IosDevice): number { const aBooted = a.state === "Booted" ? 0 : 1; const bBooted = b.state === "Booted" ? 0 : 1; @@ -103,7 +56,8 @@ export const listDevicesTool: ToolDefinition, ListDevicesR listAndroidDevices().catch(() => []), listAvds(), ]); - ios.sort(sortIos); + const iosTagged: IosDevice[] = ios.map((s) => ({ platform: "ios", ...s })); + iosTagged.sort(sortIos); const androidTagged: AndroidDevice[] = android.map((d) => ({ platform: "android", serial: d.serial, @@ -114,6 +68,14 @@ export const listDevicesTool: ToolDefinition, ListDevicesR sdkLevel: d.sdkLevel, })); androidTagged.sort(sortAndroid); - return { devices: [...ios, ...androidTagged], avds }; + + // Populate the classify cache so the next interaction tool call on any of + // these ids is a cache hit and doesn't re-run simctl + adb. + warmDeviceCache([ + ...iosTagged.map((d) => ({ udid: d.udid, platform: "ios" as const })), + ...androidTagged.map((d) => ({ udid: d.serial, platform: "android" as const })), + ]); + + return { devices: [...iosTagged, ...androidTagged], avds }; }, }; diff --git a/packages/tool-server/src/tools/interactions/describe.ts b/packages/tool-server/src/tools/interactions/describe.ts index eed4bc2b..c7228ec3 100644 --- a/packages/tool-server/src/tools/interactions/describe.ts +++ b/packages/tool-server/src/tools/interactions/describe.ts @@ -9,13 +9,16 @@ import { adaptAXDescribeToDescribeResult } from "./describe-ax-adapter"; import { adaptNativeDescribeToDescribeResult } from "./describe-native-adapter"; import { parseNativeDescribeScreenResult } from "../native-devtools/native-describe-contract"; import { resolveNativeTargetApp } from "../../utils/native-target-app"; -import { detectPlatform } from "../../utils/platform-detect"; +import { classifyDevice } from "../../utils/platform-detect"; import { adbExecOutBinary } from "../../utils/adb"; import { getAndroidScreenSize } from "../../utils/android-screen"; import { parseUiAutomatorDump } from "../../utils/uiautomator-parser"; const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() .optional() @@ -25,11 +28,17 @@ const zodSchema = z.object({ }); async function describeAndroid(udid: string): Promise { + // Per-call dump path so concurrent describes on the same serial don't race + // on /sdcard/window_dump.xml (one call's cat would read the other's dump + // mid-write). `uiautomator` rejects unwritable paths, so we target + // /data/local/tmp/ which is world-writable on every Android we support. + const randomSuffix = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`; + const dumpPath = `/data/local/tmp/argent-ui-dump-${randomSuffix}.xml`; const [size, rawBuf] = await Promise.all([ getAndroidScreenSize(udid), adbExecOutBinary( udid, - "uiautomator dump /sdcard/window_dump.xml >/dev/null && cat /sdcard/window_dump.xml", + `uiautomator dump ${dumpPath} >/dev/null && cat ${dumpPath} && rm -f ${dumpPath}`, { timeoutMs: 20_000 } ), ]); @@ -59,7 +68,7 @@ Call before every tap — never guess coordinates from a screenshot.`, zodSchema, services: () => ({}), async execute(_services, params, _options) { - if (detectPlatform(params.udid) === "android") { + if ((await classifyDevice(params.udid)) === "android") { return describeAndroid(params.udid); } const axApi = await registry.resolveService( diff --git a/packages/tool-server/src/tools/simulator/launch-app.ts b/packages/tool-server/src/tools/simulator/launch-app.ts index 9620abb1..05c79bf9 100644 --- a/packages/tool-server/src/tools/simulator/launch-app.ts +++ b/packages/tool-server/src/tools/simulator/launch-app.ts @@ -1,74 +1,98 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; -import type { ServiceRef, ToolDefinition } from "@argent/registry"; +import type { Registry, ToolDefinition } from "@argent/registry"; import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; -import { detectPlatform } from "../../utils/platform-detect"; +import { classifyDevice } from "../../utils/platform-detect"; import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); +// Android package grammar is `[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+`; +// iOS bundle ids use the same reverse-DNS shape with dashes allowed. The union +// of both platforms is letters, digits, underscore, dot, hyphen — and explicitly +// nothing else so shell metacharacters can't land in an `adb shell` template. +const BUNDLE_ID_PATTERN = /^[A-Za-z0-9._-]+$/; +// Activity names can be `.Foo`, `com.x.y/.Foo`, or `com.x/com.x.Foo`. Same alphabet +// plus `/` as the package/activity separator. `$` and other shell metacharacters +// are deliberately excluded. +const ACTIVITY_PATTERN = /^[A-Za-z0-9._/-]+$/; + const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() + .min(1) + .regex(BUNDLE_ID_PATTERN, "bundleId may only contain letters, digits, '.', '_' and '-'") .describe( "App identifier. iOS: bundle id (e.g. com.apple.MobileSMS). Android: package name from build.gradle `applicationId` (e.g. com.android.settings)." ), activity: z .string() + .min(1) + .regex(ACTIVITY_PATTERN, "activity may only contain letters, digits, '.', '_', '-' and '/'") .optional() .describe( "Android-only: fully-qualified Activity name (e.g. `.MainActivity` or `com.example/com.example.MainActivity`). If omitted on Android, the app's default launcher activity is used. Ignored on iOS." ), }); -export const launchAppTool: ToolDefinition< - z.infer, - { launched: boolean; bundleId: string } -> = { - id: "launch-app", - description: `Open an app by its bundle id (iOS) or package name (Android). +type LaunchAppParams = z.infer; + +export function createLaunchAppTool( + registry: Registry +): ToolDefinition { + return { + id: "launch-app", + description: `Open an app by its bundle id (iOS) or package name (Android). Use when starting any app — prefer this over tapping home-screen / launcher icons. Also prepares the native-devtools injection on iOS before the app starts. Returns { launched, bundleId }. Fails if the app is not installed on the target device. Common iOS bundle ids: com.apple.MobileSMS, com.apple.mobilesafari, com.apple.Preferences, com.apple.Maps, com.apple.camera, com.apple.Photos, com.apple.mobilemail, com.apple.mobilenotes, com.apple.MobileAddressBook Common Android packages: com.android.settings, com.android.chrome, com.google.android.apps.maps, com.google.android.gm, com.android.vending, com.google.android.dialer, com.google.android.apps.messaging`, - zodSchema, - services: (params): Record => - detectPlatform(params.udid) === "ios" - ? { nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}` } - : {}, - async execute(services, params) { - if (detectPlatform(params.udid) === "android") { - if (params.activity) { - const component = params.activity.startsWith(".") - ? `${params.bundleId}/${params.activity}` - : params.activity.includes("/") - ? params.activity - : `${params.bundleId}/${params.activity}`; - const out = await adbShell(params.udid, `am start -W -n ${component}`, { - timeoutMs: 30_000, - }); - if (/Error|Exception/i.test(out) && !/Status: ok/i.test(out)) { - throw new Error(`am start failed: ${out.trim()}`); - } - } else { - const out = await adbShell( - params.udid, - `monkey -p ${params.bundleId} -c android.intent.category.LAUNCHER 1`, - { timeoutMs: 30_000 } - ); - if (/No activities found|Error:/i.test(out)) { - throw new Error(`monkey launch failed: ${out.trim()}`); + zodSchema, + services: () => ({}), + async execute(_services, params) { + // Defense-in-depth: re-run schema validation. Most callers go through + // HTTP → zod, but internal paths like flow-run / flow-add-step invoke + // tools without schema parsing, so an injected bundleId could otherwise + // reach the adb-shell template below. + params = zodSchema.parse(params); + if ((await classifyDevice(params.udid)) === "android") { + if (params.activity) { + const component = params.activity.startsWith(".") + ? `${params.bundleId}/${params.activity}` + : params.activity.includes("/") + ? params.activity + : `${params.bundleId}/${params.activity}`; + const out = await adbShell(params.udid, `am start -W -n ${component}`, { + timeoutMs: 30_000, + }); + if (/Error|Exception/i.test(out) && !/Status: ok/i.test(out)) { + throw new Error(`am start failed: ${out.trim()}`); + } + } else { + const out = await adbShell( + params.udid, + `monkey -p ${params.bundleId} -c android.intent.category.LAUNCHER 1`, + { timeoutMs: 30_000 } + ); + if (/No activities found|Error:/i.test(out)) { + throw new Error(`monkey launch failed: ${out.trim()}`); + } } + return { launched: true, bundleId: params.bundleId }; } + const api = await registry.resolveService( + `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}` + ); + await api.ensureEnvReady(); + await execFileAsync("xcrun", ["simctl", "launch", params.udid, params.bundleId]); return { launched: true, bundleId: params.bundleId }; - } - const api = services.nativeDevtools as NativeDevtoolsApi; - await api.ensureEnvReady(); - await execFileAsync("xcrun", ["simctl", "launch", params.udid, params.bundleId]); - return { launched: true, bundleId: params.bundleId }; - }, -}; + }, + }; +} diff --git a/packages/tool-server/src/tools/simulator/open-url.ts b/packages/tool-server/src/tools/simulator/open-url.ts index b8909761..1173bd11 100644 --- a/packages/tool-server/src/tools/simulator/open-url.ts +++ b/packages/tool-server/src/tools/simulator/open-url.ts @@ -2,13 +2,16 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import { detectPlatform } from "../../utils/platform-detect"; +import { classifyDevice } from "../../utils/platform-detect"; import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), url: z .string() .describe( @@ -28,7 +31,7 @@ Returns { opened, url }. Fails if no app is registered to handle the URI.`, zodSchema, services: () => ({}), async execute(_services, params) { - if (detectPlatform(params.udid) === "android") { + if ((await classifyDevice(params.udid)) === "android") { const quoted = `'${params.url.replace(/'/g, "'\\''")}'`; const out = await adbShell( params.udid, diff --git a/packages/tool-server/src/tools/simulator/reinstall-app.ts b/packages/tool-server/src/tools/simulator/reinstall-app.ts index 77bc2a13..aed99714 100644 --- a/packages/tool-server/src/tools/simulator/reinstall-app.ts +++ b/packages/tool-server/src/tools/simulator/reinstall-app.ts @@ -3,13 +3,16 @@ import { promisify } from "node:util"; import { resolve as resolvePath } from "node:path"; import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import { detectPlatform } from "../../utils/platform-detect"; +import { classifyDevice } from "../../utils/platform-detect"; import { runAdb } from "../../utils/adb"; const execFileAsync = promisify(execFile); const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() .describe( @@ -43,7 +46,7 @@ Returns { reinstalled, bundleId }. Fails if the app path does not exist or the p async execute(_services, params) { const { udid, bundleId, appPath } = params; const absolute = resolvePath(appPath); - if (detectPlatform(udid) === "android") { + if ((await classifyDevice(udid)) === "android") { const args = ["-s", udid, "install", "-r"]; if (params.allowDowngrade) args.push("-d"); if (params.grantPermissions) args.push("-g"); diff --git a/packages/tool-server/src/tools/simulator/restart-app.ts b/packages/tool-server/src/tools/simulator/restart-app.ts index 33f470a9..e35cfb6d 100644 --- a/packages/tool-server/src/tools/simulator/restart-app.ts +++ b/packages/tool-server/src/tools/simulator/restart-app.ts @@ -1,54 +1,68 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; -import type { ServiceRef, ToolDefinition } from "@argent/registry"; +import type { Registry, ToolDefinition } from "@argent/registry"; import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; -import { detectPlatform } from "../../utils/platform-detect"; +import { classifyDevice } from "../../utils/platform-detect"; import { adbShell } from "../../utils/adb"; const execFileAsync = promisify(execFile); +const BUNDLE_ID_PATTERN = /^[A-Za-z0-9._-]+$/; + const zodSchema = z.object({ - udid: z.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."), - bundleId: z.string().describe("App identifier. iOS: bundle id. Android: package name."), + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + bundleId: z + .string() + .min(1) + .regex(BUNDLE_ID_PATTERN, "bundleId may only contain letters, digits, '.', '_' and '-'") + .describe("App identifier. iOS: bundle id. Android: package name."), }); -export const restartAppTool: ToolDefinition< - z.infer, - { restarted: boolean; bundleId: string } -> = { - id: "restart-app", - description: `Terminate then relaunch an app by bundle id / package name. +type RestartAppParams = z.infer; + +export function createRestartAppTool( + registry: Registry +): ToolDefinition { + return { + id: "restart-app", + description: `Terminate then relaunch an app by bundle id / package name. Use when you need a clean in-memory state without a full reinstall. Also refreshes the native-devtools injection on iOS before the relaunch. Returns { restarted, bundleId }. Fails if the app is not installed.`, - zodSchema, - services: (params): Record => - detectPlatform(params.udid) === "ios" - ? { nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}` } - : {}, - async execute(services, params) { - const { udid, bundleId } = params; - if (detectPlatform(udid) === "android") { - await adbShell(udid, `am force-stop ${bundleId}`, { timeoutMs: 15_000 }); - const out = await adbShell( - udid, - `monkey -p ${bundleId} -c android.intent.category.LAUNCHER 1`, - { timeoutMs: 30_000 } + zodSchema, + services: () => ({}), + async execute(_services, params) { + // Defense-in-depth: re-run schema validation (flow-run invokes tools + // without per-tool zod parsing, so an injected bundleId could slip past). + params = zodSchema.parse(params); + const { udid, bundleId } = params; + if ((await classifyDevice(udid)) === "android") { + await adbShell(udid, `am force-stop ${bundleId}`, { timeoutMs: 15_000 }); + const out = await adbShell( + udid, + `monkey -p ${bundleId} -c android.intent.category.LAUNCHER 1`, + { timeoutMs: 30_000 } + ); + if (/No activities found|Error:/i.test(out)) { + throw new Error(`relaunch failed: ${out.trim()}`); + } + return { restarted: true, bundleId }; + } + const api = await registry.resolveService( + `${NATIVE_DEVTOOLS_NAMESPACE}:${udid}` ); - if (/No activities found|Error:/i.test(out)) { - throw new Error(`relaunch failed: ${out.trim()}`); + await api.ensureEnvReady(); + try { + await execFileAsync("xcrun", ["simctl", "terminate", udid, bundleId]); + } catch { + // App may not be running — ignore } + await execFileAsync("xcrun", ["simctl", "launch", udid, bundleId]); return { restarted: true, bundleId }; - } - const api = services.nativeDevtools as NativeDevtoolsApi; - await api.ensureEnvReady(); - try { - await execFileAsync("xcrun", ["simctl", "terminate", udid, bundleId]); - } catch { - // App may not be running — ignore - } - await execFileAsync("xcrun", ["simctl", "launch", udid, bundleId]); - return { restarted: true, bundleId }; - }, -}; + }, + }; +} diff --git a/packages/tool-server/src/utils/ios-devices.ts b/packages/tool-server/src/utils/ios-devices.ts new file mode 100644 index 00000000..8a9530b8 --- /dev/null +++ b/packages/tool-server/src/utils/ios-devices.ts @@ -0,0 +1,48 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface IosSimulator { + udid: string; + name: string; + state: string; + runtime: string; +} + +interface SimctlDevice { + udid: string; + name: string; + state: string; + deviceTypeIdentifier: string; + isAvailable: boolean; +} + +interface SimctlOutput { + devices: Record; +} + +/** + * List all available iOS simulators via `xcrun simctl list devices --json`. + * Returns an empty array when xcrun is missing or the call fails so the + * rest of the tool surface stays usable on non-mac hosts. + */ +export async function listIosSimulators(): Promise { + try { + const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"], { + timeout: 10_000, + }); + const data: SimctlOutput = JSON.parse(stdout); + const out: IosSimulator[] = []; + for (const [runtimeId, devices] of Object.entries(data.devices)) { + if (!runtimeId.includes("iOS")) continue; + for (const d of devices) { + if (!d.isAvailable) continue; + out.push({ udid: d.udid, name: d.name, state: d.state, runtime: runtimeId }); + } + } + return out; + } catch { + return []; + } +} diff --git a/packages/tool-server/src/utils/platform-detect.ts b/packages/tool-server/src/utils/platform-detect.ts index 3f664ba5..131968e7 100644 --- a/packages/tool-server/src/utils/platform-detect.ts +++ b/packages/tool-server/src/utils/platform-detect.ts @@ -1,23 +1,81 @@ +import { listIosSimulators } from "./ios-devices"; +import { listAndroidSerials } from "./adb"; + export type Platform = "ios" | "android"; +const cache = new Map(); +const CACHE_TTL_MS = 30_000; + +/** + * Last-resort shape match used only when both `xcrun simctl list` and + * `adb devices` are unreachable (no Xcode AND no adb installed). Kept narrow + * on purpose — only the classic iOS simulator UUID (8-4-4-4-12) counts as iOS. + * The 8-16 short form is physical-device-only and cannot be driven by simctl, + * so including it here would just mis-route a caller into a "device not + * booted" error instead of a clean "unknown device" path. + */ +function matchesIosSimulatorShape(udid: string): boolean { + return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/.test(udid); +} + /** - * Classify a device id as an iOS Simulator UDID or an Android adb serial. + * Classify a device id by looking it up in the actual simctl + adb inventories. * - * iOS udids come in two shapes: - * - Classic UUID: `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (8-4-4-4-12 hex) - * - iOS 17+ short form: `XXXXXXXX-XXXXXXXXXXXXXXXX` (8-16 hex) + * Truth-from-inventory: if the id appears in `xcrun simctl list`, it is iOS; + * if it appears in `adb devices`, it is Android. When neither listing is + * reachable (no platform tooling installed) we fall back to the shape + * heuristic so the downstream tool can still attempt the action and surface + * its own "device not booted" error rather than ours. * - * Everything else — `emulator-5554`, `RF8M123`, `192.168.1.7:5555`, etc. — - * is treated as an Android adb serial. This is a lossy heuristic but it - * covers every real-world form we have seen and never misclassifies an iOS - * UDID as Android. + * Results cached per-udid for CACHE_TTL_MS so a burst of tool calls pays at + * most one pair of listing shell-outs. Cache is also warmed by `list-devices`. */ -export function detectPlatform(udid: string): Platform { - if (/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/.test(udid)) { - return "ios"; +export async function classifyDevice(udid: string): Promise { + const cached = cache.get(udid); + if (cached && cached.expiresAt > Date.now()) return cached.platform; + + const [iosHit, androidHit] = await Promise.all([udidInIosList(udid), udidInAndroidList(udid)]); + + let platform: Platform; + if (iosHit && !androidHit) { + platform = "ios"; + } else if (androidHit && !iosHit) { + platform = "android"; + } else { + // Either not-found-anywhere (unknown / not booted) or found-in-both + // (collision — never observed in practice but possible). Fall back to + // shape. The classic iOS simulator UUID is the only pattern that should + // still route to iOS; everything else is treated as adb serial because + // that's how every real-world Android serial arrives. + platform = matchesIosSimulatorShape(udid) ? "ios" : "android"; } - if (/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/.test(udid)) { - return "ios"; + + cache.set(udid, { platform, expiresAt: Date.now() + CACHE_TTL_MS }); + return platform; +} + +/** + * Pre-populate the classify cache with known-good entries — typically called + * right after `list-devices` runs so subsequent tool calls are cache hits. + */ +export function warmDeviceCache(entries: Iterable<{ udid: string; platform: Platform }>): void { + const expiresAt = Date.now() + CACHE_TTL_MS; + for (const e of entries) { + cache.set(e.udid, { platform: e.platform, expiresAt }); } - return "android"; +} + +/** Test-only: clear the cache between tests so TTL leakage doesn't masquerade as a real hit. */ +export function __resetClassifyCacheForTests(): void { + cache.clear(); +} + +async function udidInIosList(udid: string): Promise { + const sims = await listIosSimulators(); + return sims.some((s) => s.udid === udid); +} + +async function udidInAndroidList(udid: string): Promise { + const devices = await listAndroidSerials().catch(() => []); + return devices.some((d) => d.serial === udid); } diff --git a/packages/tool-server/src/utils/setup-registry.ts b/packages/tool-server/src/utils/setup-registry.ts index db0d794c..8d5cc5d6 100644 --- a/packages/tool-server/src/utils/setup-registry.ts +++ b/packages/tool-server/src/utils/setup-registry.ts @@ -14,8 +14,8 @@ import { networkInspectorBlueprint } from "../blueprints/network-inspector"; import { reactProfilerSessionBlueprint } from "../blueprints/react-profiler-session"; import { listDevicesTool } from "../tools/devices/list-devices"; import { createBootDeviceTool } from "../tools/devices/boot-device"; -import { launchAppTool } from "../tools/simulator/launch-app"; -import { restartAppTool } from "../tools/simulator/restart-app"; +import { createLaunchAppTool } from "../tools/simulator/launch-app"; +import { createRestartAppTool } from "../tools/simulator/restart-app"; import { reinstallAppTool } from "../tools/simulator/reinstall-app"; import { openUrlTool } from "../tools/simulator/open-url"; import { screenshotTool } from "../tools/interactions/screenshot"; @@ -82,8 +82,8 @@ export function createRegistry(): Registry { registry.registerTool(listDevicesTool); registry.registerTool(createBootDeviceTool(registry)); - registry.registerTool(launchAppTool); - registry.registerTool(restartAppTool); + registry.registerTool(createLaunchAppTool(registry)); + registry.registerTool(createRestartAppTool(registry)); registry.registerTool(reinstallAppTool); registry.registerTool(openUrlTool); registry.registerTool(screenshotTool); diff --git a/packages/tool-server/test/classify-device.test.ts b/packages/tool-server/test/classify-device.test.ts new file mode 100644 index 00000000..e9ac7e2d --- /dev/null +++ b/packages/tool-server/test/classify-device.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock execFile so we can pretend xcrun / adb are present or absent per test. +const execFileMock = vi.fn(); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { + classifyDevice, + warmDeviceCache, + __resetClassifyCacheForTests, +} from "../src/utils/platform-detect"; + +const iosUuid = "11111111-2222-3333-4444-555555555555"; +const androidSerial = "emulator-5554"; + +function simctlJsonWith(udids: string[]): string { + return JSON.stringify({ + devices: { + "com.apple.CoreSimulator.SimRuntime.iOS-18-2": udids.map((udid, i) => ({ + udid, + name: `Sim ${i}`, + state: "Shutdown", + deviceTypeIdentifier: "...", + isAvailable: true, + })), + }, + }); +} + +function adbDevicesWith(serials: string[]): string { + return ["List of devices attached", ...serials.map((s) => `${s}\tdevice`), ""].join("\n"); +} + +beforeEach(() => { + execFileMock.mockReset(); + __resetClassifyCacheForTests(); +}); + +afterEach(() => { + __resetClassifyCacheForTests(); +}); + +describe("classifyDevice — list-based truth", () => { + it("returns `ios` when simctl lists the udid (authoritative, not shape-based)", async () => { + // The id has Android-serial shape (`emulator-XXXX`) but simctl claims it. + // Authoritative source wins over shape so the dispatch is right even for + // Apple's future id formats we don't know about. + const surprising = "emulator-9999"; + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "xcrun" && args[0] === "simctl") { + return { stdout: simctlJsonWith([surprising]), stderr: "" }; + } + if (cmd === "adb") { + return { stdout: adbDevicesWith([]), stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + expect(await classifyDevice(surprising)).toBe("ios"); + }); + + it("returns `android` when adb lists the udid", async () => { + execFileMock.mockImplementation((cmd: string) => { + if (cmd === "xcrun") return { stdout: simctlJsonWith([]), stderr: "" }; + if (cmd === "adb") return { stdout: adbDevicesWith([androidSerial]), stderr: "" }; + return { stdout: "", stderr: "" }; + }); + + expect(await classifyDevice(androidSerial)).toBe("android"); + }); + + it("falls back to shape when neither tool is installed — UUID → iOS", async () => { + // No xcrun, no adb. The device isn't booted either way, but we still want + // a reasonable guess so the caller's subsequent launch attempt can fail + // with its own message instead of ours. + execFileMock.mockImplementation(() => new Error("command not found")); + expect(await classifyDevice(iosUuid)).toBe("ios"); + }); + + it("falls back to shape when neither tool is installed — non-UUID → android", async () => { + execFileMock.mockImplementation(() => new Error("command not found")); + expect(await classifyDevice("emulator-5554")).toBe("android"); + }); + + it("drops the iOS-17 short form from the shape fallback — it is physical-device-only", async () => { + // Physical iOS devices can't be driven by simctl, so classifying an + // 8-16 form as iOS would just route the caller into an opaque simctl + // "Invalid device" error. Treating it as android-unknown lets the + // Android code path surface its own "device not found" error instead. + execFileMock.mockImplementation(() => new Error("command not found")); + const shortForm = "00008030-001C25120C22802E"; + expect(await classifyDevice(shortForm)).toBe("android"); + }); +}); + +describe("classifyDevice — caching", () => { + it("hits the cache on the second call; does not re-shell", async () => { + let calls = 0; + execFileMock.mockImplementation((cmd: string) => { + calls += 1; + if (cmd === "xcrun") return { stdout: simctlJsonWith([iosUuid]), stderr: "" }; + if (cmd === "adb") return { stdout: adbDevicesWith([]), stderr: "" }; + return { stdout: "", stderr: "" }; + }); + + expect(await classifyDevice(iosUuid)).toBe("ios"); + const callsAfterFirst = calls; + expect(await classifyDevice(iosUuid)).toBe("ios"); + expect(calls).toBe(callsAfterFirst); // cache hit — no new shell-outs + }); + + it("warmDeviceCache populates the cache so the first tool call is O(1)", async () => { + // This is the contract list-devices relies on: after it runs, every + // interaction tool for a listed udid should classify instantly. + warmDeviceCache([ + { udid: iosUuid, platform: "ios" }, + { udid: androidSerial, platform: "android" }, + ]); + expect(await classifyDevice(iosUuid)).toBe("ios"); + expect(await classifyDevice(androidSerial)).toBe("android"); + expect(execFileMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tool-server/test/describe-android-dispatch.test.ts b/packages/tool-server/test/describe-android-dispatch.test.ts index 52206079..ceaa5ed1 100644 --- a/packages/tool-server/test/describe-android-dispatch.test.ts +++ b/packages/tool-server/test/describe-android-dispatch.test.ts @@ -22,6 +22,7 @@ vi.mock("node:child_process", async () => { }); import { createDescribeTool } from "../src/tools/interactions/describe"; +import { __resetClassifyCacheForTests, warmDeviceCache } from "../src/utils/platform-detect"; const fakeRegistry: Registry = { resolveService: vi.fn(), @@ -31,10 +32,17 @@ const fakeRegistry: Registry = { // `wm size` output for 5 s per serial. Reusing a serial across tests leaks the // first test's mocked screen size into the second. let nextSerial = 7000; -const mkSerial = () => `emulator-${nextSerial++}`; +const mkSerial = () => { + const s = `emulator-${nextSerial++}`; + // Warm the classify cache so describe's platform check is O(1) and doesn't + // shell out to xcrun / adb list lookups. + warmDeviceCache([{ udid: s, platform: "android" }]); + return s; +}; beforeEach(() => { execFileMock.mockReset(); + __resetClassifyCacheForTests(); }); function sampleDump(): string { diff --git a/packages/tool-server/test/launch-app-dispatch.test.ts b/packages/tool-server/test/launch-app-dispatch.test.ts index 9afac65f..e53fe9c8 100644 --- a/packages/tool-server/test/launch-app-dispatch.test.ts +++ b/packages/tool-server/test/launch-app-dispatch.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Registry } from "@argent/registry"; -// Mock the child_process boundary so we don't actually shell out to xcrun / adb. const execFileMock = vi.fn(); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); @@ -12,7 +12,6 @@ vi.mock("node:child_process", async () => { opts: unknown, cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void ) => { - // promisify(execFile) calls it as `execFile(cmd, args, opts, cb)` — cb is the last arg. const callback = typeof opts === "function" ? opts : cb!; const options = typeof opts === "function" ? undefined : opts; const result = execFileMock(cmd, args, options); @@ -22,41 +21,48 @@ vi.mock("node:child_process", async () => { }; }); -import { launchAppTool } from "../src/tools/simulator/launch-app"; +import { createLaunchAppTool } from "../src/tools/simulator/launch-app"; +import { __resetClassifyCacheForTests, warmDeviceCache } from "../src/utils/platform-detect"; const iosUdid = "11111111-2222-3333-4444-555555555555"; const androidSerial = "emulator-5554"; + const iosNativeApi = { ensureEnvReady: vi.fn().mockResolvedValue(undefined) }; +const resolveService = vi.fn(async () => iosNativeApi); +const registry = { resolveService } as unknown as Registry; beforeEach(() => { execFileMock.mockReset().mockReturnValue({ stdout: "", stderr: "" }); iosNativeApi.ensureEnvReady.mockClear().mockResolvedValue(undefined); + resolveService.mockClear().mockResolvedValue(iosNativeApi); + __resetClassifyCacheForTests(); + // Pre-populate the classify cache so tests don't shell out for xcrun / adb + // list lookups (those paths are covered separately in classify-device.test.ts). + warmDeviceCache([ + { udid: iosUdid, platform: "ios" }, + { udid: androidSerial, platform: "android" }, + ]); }); -describe("launch-app.services — platform-dependent ServiceRef", () => { - it("requests the nativeDevtools service for iOS udids", () => { - expect(launchAppTool.services({ udid: iosUdid, bundleId: "com.example" })).toEqual({ - nativeDevtools: `NativeDevtools:${iosUdid}`, - }); - }); - - it("requests no services for Android serials — avoids spawning the iOS-only NativeDevtools service", () => { - // This is critical: NativeDevtools depends on xcrun simctl APIs and will - // blow up on non-UUID udids. A stray nativeDevtools request for an - // Android serial would break every Android launch. - expect(launchAppTool.services({ udid: androidSerial, bundleId: "com.example" })).toEqual({}); +describe("launch-app.services — no pre-declared services (factory form)", () => { + it("declares no services; platform-specific service resolution is deferred to execute", () => { + // We moved NativeDevtools resolution into execute so the platform check + // can be async (list-based classifyDevice). If a future change re-adds a + // service request here, the udid-shape it would use is an iOS-only URN + // that would fail for Android devices. + const tool = createLaunchAppTool(registry); + expect(tool.services({ udid: iosUdid, bundleId: "com.example" })).toEqual({}); + expect(tool.services({ udid: androidSerial, bundleId: "com.example" })).toEqual({}); }); }); -describe("launch-app.execute — iOS path (unchanged behavior)", () => { +describe("launch-app.execute — iOS path (behavior preserved through factory refactor)", () => { it("prepares native devtools then calls `xcrun simctl launch`", async () => { - await launchAppTool.execute!( - { nativeDevtools: iosNativeApi }, - { udid: iosUdid, bundleId: "com.apple.Preferences" } - ); + const tool = createLaunchAppTool(registry); + await tool.execute!({}, { udid: iosUdid, bundleId: "com.apple.Preferences" }); + expect(resolveService).toHaveBeenCalledWith(`NativeDevtools:${iosUdid}`); expect(iosNativeApi.ensureEnvReady).toHaveBeenCalledTimes(1); - expect(execFileMock).toHaveBeenCalledTimes(1); expect(execFileMock).toHaveBeenCalledWith( "xcrun", ["simctl", "launch", iosUdid, "com.apple.Preferences"], @@ -64,7 +70,7 @@ describe("launch-app.execute — iOS path (unchanged behavior)", () => { ); }); - it("ensureEnvReady is awaited *before* launch (injection must be in place pre-spawn)", async () => { + it("ensureEnvReady awaits *before* launch (injection must be in place pre-spawn)", async () => { const order: string[] = []; iosNativeApi.ensureEnvReady.mockImplementation(async () => { order.push("ensureEnvReady"); @@ -74,17 +80,15 @@ describe("launch-app.execute — iOS path (unchanged behavior)", () => { return { stdout: "", stderr: "" }; }); - await launchAppTool.execute!( - { nativeDevtools: iosNativeApi }, - { udid: iosUdid, bundleId: "com.apple.Preferences" } - ); - + const tool = createLaunchAppTool(registry); + await tool.execute!({}, { udid: iosUdid, bundleId: "com.apple.Preferences" }); expect(order).toEqual(["ensureEnvReady", "xcrun"]); }); it("ignores an `activity` arg on iOS (Android-only parameter)", async () => { - await launchAppTool.execute!( - { nativeDevtools: iosNativeApi }, + const tool = createLaunchAppTool(registry); + await tool.execute!( + {}, { udid: iosUdid, bundleId: "com.apple.Preferences", activity: ".Root" } ); expect(execFileMock).toHaveBeenCalledWith( @@ -97,7 +101,8 @@ describe("launch-app.execute — iOS path (unchanged behavior)", () => { describe("launch-app.execute — Android path", () => { it("defaults to `monkey` LAUNCHER intent when no activity is provided", async () => { - await launchAppTool.execute!({}, { udid: androidSerial, bundleId: "com.android.settings" }); + const tool = createLaunchAppTool(registry); + await tool.execute!({}, { udid: androidSerial, bundleId: "com.android.settings" }); expect(execFileMock).toHaveBeenCalledWith( "adb", [ @@ -108,13 +113,14 @@ describe("launch-app.execute — Android path", () => { ], expect.any(Object) ); - // Critically, NO xcrun call — running iOS tooling for an Android device is - // the exact class of regression this test guards against. - expect(execFileMock).not.toHaveBeenCalledWith("xcrun", expect.anything(), expect.anything()); + // NativeDevtools (iOS-only) must NOT be resolved on the Android path — + // its factory would blow up trying to launchctl into a non-existent sim. + expect(resolveService).not.toHaveBeenCalled(); }); it("uses `am start -W -n pkg/.Activity` when activity starts with a dot", async () => { - await launchAppTool.execute!( + const tool = createLaunchAppTool(registry); + await tool.execute!( {}, { udid: androidSerial, bundleId: "com.example.app", activity: ".MainActivity" } ); @@ -126,7 +132,8 @@ describe("launch-app.execute — Android path", () => { }); it("passes pre-qualified `pkg/.Activity` strings through unchanged", async () => { - await launchAppTool.execute!( + const tool = createLaunchAppTool(registry); + await tool.execute!( {}, { udid: androidSerial, @@ -146,8 +153,9 @@ describe("launch-app.execute — Android path", () => { stdout: "Error: Activity class {com.foo/.Bar} does not exist.", stderr: "", }); + const tool = createLaunchAppTool(registry); await expect( - launchAppTool.execute!({}, { udid: androidSerial, bundleId: "com.foo", activity: ".Bar" }) + tool.execute!({}, { udid: androidSerial, bundleId: "com.foo", activity: ".Bar" }) ).rejects.toThrow(/am start failed/); }); @@ -156,8 +164,9 @@ describe("launch-app.execute — Android path", () => { stdout: "** No activities found to run, monkey aborted.", stderr: "", }); + const tool = createLaunchAppTool(registry); await expect( - launchAppTool.execute!({}, { udid: androidSerial, bundleId: "com.not.installed" }) + tool.execute!({}, { udid: androidSerial, bundleId: "com.not.installed" }) ).rejects.toThrow(/monkey launch failed/); }); }); diff --git a/packages/tool-server/test/platform-detect.test.ts b/packages/tool-server/test/platform-detect.test.ts deleted file mode 100644 index 0686bd6f..00000000 --- a/packages/tool-server/test/platform-detect.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { detectPlatform } from "../src/utils/platform-detect"; - -describe("detectPlatform", () => { - it("recognizes the classic iOS UDID (8-4-4-4-12 hex)", () => { - expect(detectPlatform("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")).toBe("ios"); - expect(detectPlatform("00000000-0000-0000-0000-000000000000")).toBe("ios"); - // Any case works. - expect(detectPlatform("abcdef12-3456-7890-abcd-ef1234567890")).toBe("ios"); - }); - - it("recognizes the iOS 17+ short UDID (8-16 hex)", () => { - expect(detectPlatform("00008030-001C25120C22802E")).toBe("ios"); - expect(detectPlatform("ffffffff-0000000000000000")).toBe("ios"); - }); - - it("treats Android emulator serials as android", () => { - expect(detectPlatform("emulator-5554")).toBe("android"); - expect(detectPlatform("emulator-5556")).toBe("android"); - }); - - it("treats physical Android serials as android", () => { - expect(detectPlatform("R5CT12345678")).toBe("android"); - expect(detectPlatform("HT7901A01234")).toBe("android"); - }); - - it("treats Android network serials (host:port) as android", () => { - expect(detectPlatform("192.168.1.50:5555")).toBe("android"); - }); - - it("treats malformed or short ids as android (safe default — iOS simctl would reject them immediately anyway)", () => { - expect(detectPlatform("ABC")).toBe("android"); - expect(detectPlatform("")).toBe("android"); - expect(detectPlatform("12345")).toBe("android"); - }); - - it("does not misclassify a UDID with non-hex characters as iOS", () => { - // Shape matches 8-4-4-4-12 but contains a non-hex char (G) - expect(detectPlatform("GGGGGGGG-1111-2222-3333-444444444444")).toBe("android"); - }); -}); diff --git a/packages/tool-server/test/reinstall-app-dispatch.test.ts b/packages/tool-server/test/reinstall-app-dispatch.test.ts index e950b4cc..db98eb02 100644 --- a/packages/tool-server/test/reinstall-app-dispatch.test.ts +++ b/packages/tool-server/test/reinstall-app-dispatch.test.ts @@ -22,12 +22,21 @@ vi.mock("node:child_process", async () => { }); import { reinstallAppTool } from "../src/tools/simulator/reinstall-app"; +import { __resetClassifyCacheForTests, warmDeviceCache } from "../src/utils/platform-detect"; const iosUdid = "11111111-2222-3333-4444-555555555555"; const androidSerial = "emulator-5554"; beforeEach(() => { execFileMock.mockReset().mockReturnValue({ stdout: "", stderr: "" }); + __resetClassifyCacheForTests(); + // Pre-populate the classify cache so the platform branch doesn't shell out + // to `xcrun simctl list` / `adb devices` (that's what classify-device.test.ts + // covers). Here we only care about the reinstall tool's own behavior. + warmDeviceCache([ + { udid: iosUdid, platform: "ios" }, + { udid: androidSerial, platform: "android" }, + ]); }); describe("reinstall-app — iOS path (unchanged semantics)", () => { diff --git a/packages/tool-server/test/restart-app-dispatch.test.ts b/packages/tool-server/test/restart-app-dispatch.test.ts index ac9daff6..3f687c9e 100644 --- a/packages/tool-server/test/restart-app-dispatch.test.ts +++ b/packages/tool-server/test/restart-app-dispatch.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Registry } from "@argent/registry"; const execFileMock = vi.fn(); vi.mock("node:child_process", async () => { @@ -20,36 +21,40 @@ vi.mock("node:child_process", async () => { }; }); -import { restartAppTool } from "../src/tools/simulator/restart-app"; +import { createRestartAppTool } from "../src/tools/simulator/restart-app"; +import { __resetClassifyCacheForTests, warmDeviceCache } from "../src/utils/platform-detect"; const iosUdid = "11111111-2222-3333-4444-555555555555"; const androidSerial = "emulator-5554"; const iosNativeApi = { ensureEnvReady: vi.fn().mockResolvedValue(undefined) }; +const resolveService = vi.fn(async () => iosNativeApi); +const registry = { resolveService } as unknown as Registry; beforeEach(() => { execFileMock.mockReset().mockReturnValue({ stdout: "", stderr: "" }); iosNativeApi.ensureEnvReady.mockClear().mockResolvedValue(undefined); + resolveService.mockClear().mockResolvedValue(iosNativeApi); + __resetClassifyCacheForTests(); + warmDeviceCache([ + { udid: iosUdid, platform: "ios" }, + { udid: androidSerial, platform: "android" }, + ]); }); -describe("restart-app.services", () => { - it("requests nativeDevtools on iOS so the AX injection is ready pre-launch", () => { - expect(restartAppTool.services({ udid: iosUdid, bundleId: "com.foo" })).toEqual({ - nativeDevtools: `NativeDevtools:${iosUdid}`, - }); - }); - - it("requests no services on Android — NativeDevtools is iOS-only", () => { - expect(restartAppTool.services({ udid: androidSerial, bundleId: "com.foo" })).toEqual({}); +describe("restart-app.services — no pre-declared services (factory form)", () => { + it("declares no services; platform-specific service resolution is deferred to execute", () => { + const tool = createRestartAppTool(registry); + expect(tool.services({ udid: iosUdid, bundleId: "com.foo" })).toEqual({}); + expect(tool.services({ udid: androidSerial, bundleId: "com.foo" })).toEqual({}); }); }); describe("restart-app.execute — iOS (behaviour preserved)", () => { it("terminates then launches via simctl, refreshing native-devtools between", async () => { - await restartAppTool.execute!( - { nativeDevtools: iosNativeApi }, - { udid: iosUdid, bundleId: "com.apple.Preferences" } - ); + const tool = createRestartAppTool(registry); + await tool.execute!({}, { udid: iosUdid, bundleId: "com.apple.Preferences" }); + expect(resolveService).toHaveBeenCalledWith(`NativeDevtools:${iosUdid}`); expect(iosNativeApi.ensureEnvReady).toHaveBeenCalledTimes(1); expect(execFileMock).toHaveBeenCalledTimes(2); expect(execFileMock.mock.calls[0]![1]).toEqual([ @@ -74,18 +79,17 @@ describe("restart-app.execute — iOS (behaviour preserved)", () => { return { stdout: "", stderr: "" }; }); - const result = await restartAppTool.execute!( - { nativeDevtools: iosNativeApi }, - { udid: iosUdid, bundleId: "com.apple.Preferences" } - ); + const tool = createRestartAppTool(registry); + const result = await tool.execute!({}, { udid: iosUdid, bundleId: "com.apple.Preferences" }); expect(result).toEqual({ restarted: true, bundleId: "com.apple.Preferences" }); expect(execFileMock).toHaveBeenCalledTimes(2); }); }); describe("restart-app.execute — Android", () => { - it("force-stops then monkey-launches — no xcrun calls", async () => { - await restartAppTool.execute!({}, { udid: androidSerial, bundleId: "com.android.settings" }); + it("force-stops then monkey-launches — no xcrun, no NativeDevtools resolve", async () => { + const tool = createRestartAppTool(registry); + await tool.execute!({}, { udid: androidSerial, bundleId: "com.android.settings" }); expect(execFileMock).toHaveBeenCalledTimes(2); expect(execFileMock.mock.calls[0]![1]).toEqual([ "-s", @@ -100,6 +104,7 @@ describe("restart-app.execute — Android", () => { "monkey -p com.android.settings -c android.intent.category.LAUNCHER 1", ]); expect(execFileMock).not.toHaveBeenCalledWith("xcrun", expect.anything(), expect.anything()); + expect(resolveService).not.toHaveBeenCalled(); }); it("throws when monkey cannot find an activity to relaunch", async () => { @@ -115,8 +120,9 @@ describe("restart-app.execute — Android", () => { return { stdout: "", stderr: "" }; }); + const tool = createRestartAppTool(registry); await expect( - restartAppTool.execute!({}, { udid: androidSerial, bundleId: "com.not.installed" }) + tool.execute!({}, { udid: androidSerial, bundleId: "com.not.installed" }) ).rejects.toThrow(/relaunch failed/); }); }); diff --git a/packages/tool-server/test/simulator-server-blueprint.test.ts b/packages/tool-server/test/simulator-server-blueprint.test.ts index 01a1ebac..d0aebd2b 100644 --- a/packages/tool-server/test/simulator-server-blueprint.test.ts +++ b/packages/tool-server/test/simulator-server-blueprint.test.ts @@ -12,10 +12,22 @@ import { Readable } from "node:stream"; const spawnMock = vi.fn(); const ensureAutomationEnabledMock = vi.fn(); +// classifyDevice shells out to xcrun + adb. We stub execFile so tests are +// hermetic — the stub returns empty results, which makes classify fall back +// to the shape heuristic (covered comprehensively in classify-device.test.ts). +const execFileMock = vi.fn().mockImplementation((_cmd, _args, opts, cb) => { + const callback = typeof opts === "function" ? opts : cb!; + callback(new Error("stubbed"), { stdout: "", stderr: "" }); +}); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); - return { ...actual, spawn: spawnMock }; + return { + ...actual, + spawn: spawnMock, + execFile: (cmd: string, args: readonly string[], opts: unknown, cb?: unknown) => + execFileMock(cmd, args, opts, cb), + }; }); vi.mock("../src/blueprints/ax-service", () => ({ @@ -53,9 +65,12 @@ function signalReady(proc: ReturnType, port: number) { } describe("simulatorServerBlueprint.factory — dispatch on udid shape", () => { - beforeEach(() => { + beforeEach(async () => { spawnMock.mockReset(); ensureAutomationEnabledMock.mockReset().mockResolvedValue(undefined); + // Reset classify cache so each test's first call re-runs the (stubbed) lookup. + const { __resetClassifyCacheForTests } = await import("../src/utils/platform-detect"); + __resetClassifyCacheForTests(); }); afterEach(() => { @@ -112,19 +127,25 @@ describe("simulatorServerBlueprint.factory — dispatch on udid shape", () => { expect(ensureAutomationEnabledMock).not.toHaveBeenCalled(); }); - it("also dispatches to `android` for the iOS-17 short UUID form? — no, it stays on `ios`", async () => { + it("does NOT route the iOS-17 physical-device short UUID form to `ios` (simctl cannot drive physical devices)", async () => { + // Review issue #8: the 8-16 hex form is physical-device-only. Routing it + // to `ios` surfaced an opaque "Invalid device" error from simctl. With + // list-based classify, an id that isn't in simctl's list falls back to + // the android subcommand — the caller gets "device not found" from adb, + // which at least correctly signals "this tool stack does not drive that + // target" rather than pretending simctl might work. const fakeProc = makeFakeProc(); spawnMock.mockReturnValue(fakeProc); const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); - // iOS 17+ physical-device short form (8-16 hex). - const udid = "00008030-001C25120C22802E"; - const factoryPromise = simulatorServerBlueprint.factory({}, udid); + const shortForm = "00008030-001C25120C22802E"; + const factoryPromise = simulatorServerBlueprint.factory({}, shortForm); signalReady(fakeProc, 55557); await factoryPromise; - expect(spawnMock.mock.calls[0]![1]).toEqual(["ios", "--id", udid]); - expect(ensureAutomationEnabledMock).toHaveBeenCalledWith(udid); + // No longer routed to `ios` (was a regression in the shape-heuristic world). + expect(spawnMock.mock.calls[0]![1]![0]).toBe("android"); + expect(ensureAutomationEnabledMock).not.toHaveBeenCalled(); }); it("pressKey writes the shared stdin command protocol regardless of platform", async () => { From bfa5982c7a2279860c08196e35818eae6a7494f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 16:51:39 +0200 Subject: [PATCH 008/149] fix(security): validate bundleId/activity/udid on the Android adb-shell surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings #1 and #7. Every Android branch interpolates user-supplied `bundleId` (and sometimes `activity` / `tag`) directly into an `adb -s shell "