diff --git a/README.md b/README.md index c79918e9..8f75b6db 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,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 @@ -14,7 +14,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. @@ -33,8 +33,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/argent-cli/src/run.ts b/packages/argent-cli/src/run.ts index 5f54941a..451308af 100644 --- a/packages/argent-cli/src/run.ts +++ b/packages/argent-cli/src/run.ts @@ -120,7 +120,7 @@ Invoke a tool exposed by the argent tool-server. Run \`argent tools\` to list available tools, or \`argent tools describe \` to see one tool's flags. Examples: - argent run list-simulators + argent run list-devices argent run gesture-tap --udid --x 0.5 --y 0.5 argent run screenshot --udid --out ./screen.png argent run run-sequence --udid --steps-json '[{"tool":"button","args":{"button":"home"}}]' diff --git a/packages/argent-installer/test/skills.test.ts b/packages/argent-installer/test/skills.test.ts index 58cf8bef..e11ef2f0 100644 --- a/packages/argent-installer/test/skills.test.ts +++ b/packages/argent-installer/test/skills.test.ts @@ -75,7 +75,7 @@ describe("refreshArgentSkills", () => { }); it("resyncs a tracked project scope when the lock has an argent skill", () => { - listBundledSkillsMock.mockReturnValue(["argent-create-flow", "argent-simulator-setup"]); + listBundledSkillsMock.mockReturnValue(["argent-create-flow", "argent-ios-simulator-setup"]); writeLock(path.join(tmpDir, "skills-lock.json"), { "argent-create-flow": {}, }); diff --git a/packages/argent-installer/test/update.test.ts b/packages/argent-installer/test/update.test.ts index 7d732e0e..2fde1a95 100644 --- a/packages/argent-installer/test/update.test.ts +++ b/packages/argent-installer/test/update.test.ts @@ -8,7 +8,6 @@ import { detectPackageManager, globalInstallCommand, formatShellCommand, - isTempRunnerPath, } from "../src/utils.js"; import { PACKAGE_NAME, NPM_REGISTRY } from "../src/constants.js"; @@ -68,33 +67,6 @@ describe("update — constants are correct", () => { }); }); -describe("update — temp runner detection", () => { - // npx-cached argent shares the latest version, so without this filter the - // version compare would falsely match latest after the user uninstalled the - // global package via `npx @swmansion/argent uninstall`. - it("flags npx cache paths as transient", () => { - expect(isTempRunnerPath("/Users/me/.npm/_npx/abc123/node_modules/.bin/argent")).toBe(true); - }); - - it("flags pnpm dlx cache paths as transient", () => { - expect(isTempRunnerPath("/Users/me/.pnpm-store/dlx-1234/node_modules/.bin/argent")).toBe(true); - }); - - it("flags bun install cache paths as transient", () => { - expect(isTempRunnerPath("/Users/me/.bun/install/cache/argent")).toBe(true); - }); - - it("flags Windows dlx cache paths as transient", () => { - expect(isTempRunnerPath("C:\\Users\\me\\AppData\\Local\\dlx-abc\\argent.cmd")).toBe(true); - }); - - it("treats real global install paths as permanent", () => { - expect(isTempRunnerPath("/usr/local/bin/argent")).toBe(false); - expect(isTempRunnerPath("/opt/homebrew/bin/argent")).toBe(false); - expect(isTempRunnerPath("C:\\Users\\me\\AppData\\Roaming\\npm\\argent.cmd")).toBe(false); - }); -}); - describe("update — registry safety", () => { it("globalInstallCommand never includes --registry (relies on .npmrc scoped registry)", () => { for (const pm of ["npm", "yarn", "pnpm", "bun"] as const) { diff --git a/packages/argent-mcp/src/mcp-server.ts b/packages/argent-mcp/src/mcp-server.ts index d6621474..968b32b2 100644 --- a/packages/argent-mcp/src/mcp-server.ts +++ b/packages/argent-mcp/src/mcp-server.ts @@ -150,7 +150,7 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise { }); 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/argent/package.json b/packages/argent/package.json index 9c7d1540..72faef66 100644 --- a/packages/argent/package.json +++ b/packages/argent/package.json @@ -1,7 +1,7 @@ { "name": "@swmansion/argent", "version": "0.6.1", - "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/argent/scripts/bundle-tools.cjs b/packages/argent/scripts/bundle-tools.cjs index a946ebd7..39cff9c6 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -180,7 +180,7 @@ if (fs.existsSync(UI_SRC)) { console.warn(`⚠ Preview UI not found at ${UI_SRC} — skipping copy`); } -// Copy Argent.tracetemplate so ios-profiler-start can find it at runtime. +// Copy Argent.tracetemplate so native-profiler-start can find it at runtime. const TRACE_TEMPLATE_SRC = path.resolve( WORKSPACE_ROOT, "packages/tool-server/src/utils/ios-profiler/Argent.tracetemplate" diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts index d897d0d0..defa8f3d 100644 --- a/packages/registry/src/types.ts +++ b/packages/registry/src/types.ts @@ -119,7 +119,7 @@ export interface ToolCapability { * On a missing binary, the HTTP layer returns 424 Failed Dependency with an * install hint the agent can surface verbatim. */ -export type ToolDependency = "adb" | "xcrun"; +export type ToolDependency = "adb" | "xcrun" | "emulator"; // ── Tool Types ── diff --git a/packages/skills/agents/argent-environment-inspector.md b/packages/skills/agents/argent-environment-inspector.md index 8f311551..ae2159d6 100644 --- a/packages/skills/agents/argent-environment-inspector.md +++ b/packages/skills/agents/argent-environment-inspector.md @@ -45,7 +45,7 @@ but required by the main agent, fill it in by manual inspection of the project. - `expo` in dependencies or `app.json` with `expo` key → Expo project - `pubspec.yaml` present → Flutter project - `ios/*.xcodeproj` or `ios/*.xcworkspace` without `react-native` → native iOS - - `android/build.gradle` without `react-native` → native Android + - `android/build.gradle` or `android/build.gradle.kts` without `react-native` → native Android - None of the above → classify based on what you find (web app, library, etc.) 3. **Explore beyond the snapshot.** Use Read, Glob, Grep, and Bash to fill @@ -64,7 +64,7 @@ but required by the main agent, fill it in by manual inspection of the project. flows (`.maestro/`). - For Flutter: `pubspec.yaml`, `analysis_options.yaml`, `lib/` structure. - For native iOS: Xcode project/workspace, schemes, `Podfile`, `Package.swift`. - - For native Android: `build.gradle`, `settings.gradle`, flavor configs. + - For native Android: `build.gradle` / `build.gradle.kts`, `settings.gradle` / `settings.gradle.kts`, flavor configs. 4. **Populate every field** in the output schema below. Use `null` for genuinely unknown values or fields that do not apply to this project type. @@ -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 | +| `ios_has_podfile` | bool | True when `ios/Podfile` exists | +| `android_has_gradle` | bool | True when `android/gradlew` exists | | `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/agents/references/quality-control-checklist.md b/packages/skills/agents/references/quality-control-checklist.md index a9688b49..7640d465 100644 --- a/packages/skills/agents/references/quality-control-checklist.md +++ b/packages/skills/agents/references/quality-control-checklist.md @@ -41,5 +41,5 @@ Look for these beyond the obvious lint/test configs, regardless of project type: - `lint-staged` config — what runs on commit - CI config files — the CI steps are ground truth for what "passing" means - `Podfile` / `Package.swift` — iOS dependency management -- `build.gradle` / `settings.gradle` — Android build config and flavor definitions +- `build.gradle` / `build.gradle.kts` / `settings.gradle` / `settings.gradle.kts` — Android build config and flavor definitions (Groovy or Kotlin DSL) - `pubspec.yaml` / `analysis_options.yaml` — Flutter project config and lint rules diff --git a/packages/skills/package.json b/packages/skills/package.json index f98e79d4..00f40179 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -3,7 +3,7 @@ "name": "@argent/skills", "version": "0.6.1", "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 6bb8a24d..ff856e7a 100644 --- a/packages/skills/rules/argent.md +++ b/packages/skills/rules/argent.md @@ -1,39 +1,39 @@ --- -description: Argent iOS Simulator Agent — always-on guidance for methodology and tools for working with, interacting, testing and profiling mobile app work +description: Argent iOS Simulator and Android Emulator Agent — always-on guidance for methodology and tools for working with, interacting, testing and profiling mobile app work 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 or Android) **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. -- `native-describe-screen` - accessibility screen description via injected native devtools +- `describe` - native app-level components and safely targetable foreground apps (iOS and Android). +- `native-describe-screen` - accessibility screen description via injected native devtools (iOS only) - `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. +`native-user-interactable-view-at-point` / `native-view-at-point` are follow-up diagnostics once you already have a candidate point (iOS only). 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. 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 `describe` fails, **read the exact error before reacting**, follow the recovery guidance in `argent-device-interact` to choose the correct next action. -Before starting to interact with the app, read the `argent-simulator-interact` skill first. +Before starting to interact with the app, read the `argent-device-interact` skill first. @@ -42,13 +42,13 @@ 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`, +- All simulator/emulator interactions go through argent MCP tools — never use `xcrun simctl`, raw `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 `argent-simulator-interact` skill. +- Always use `run-sequence` when performing multiple sequential simulator actions where you don't need to observe the screen between steps. More in `argent-device-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. @@ -69,17 +69,21 @@ When `is_react_native` is true: load `argent-react-native-app-workflow` skill. U 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 -Skill: `argent-simulator-setup` -When: Beginning a task that involves the simulator, no simulator booted yet, need UDID or simulator-server. +iOS SIMULATOR SETUP +Skill: `argent-ios-simulator-setup` +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 an adb serial, or about to install an APK. TAPPING, SWIPING, TYPING, GESTURES, SCREENSHOTS, SCROLLING -Skill: `argent-simulator-interact` +Skill: `argent-device-interact` When: Performing touch interactions, typing, pressing hardware buttons, launching/restarting apps, opening URLs, rotating device, 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 or Android app, build failures, pod issues, lost Metro connection, reading logs, reloading JS bundle, reinstalling app. JS EVALUATION, METRO CONNECTION, REACT NATIVE Skill: `argent-metro-debugger` @@ -89,9 +93,9 @@ REACT APP & COMPONENT PROFILING Use skill: `argent-react-native-profiler` When: To measure performance of specific components, to find app-wide bottlenecks. Investigating re-renders or CPU hotspots, producing ranked performance reports. -NATIVE iOS PROFILING -Use skill: `argent-ios-profiler` -When: Profiling native iOS performance (CPU hotspots, UI hangs, memory leaks via Instruments). Useful as a reference for iOS-specific investigation when running dual profiling via `argent-react-native-profiler`. +NATIVE PROFILING +Use skill: `argent-native-profiler` +When: Profiling native performance (CPU hotspots, UI hangs, memory leaks). iOS only today; Android on the roadmap. Useful as a reference for platform-specific investigation when running dual profiling via `argent-react-native-profiler`. PERFORMANCE OPTIMIZATION Use skill: `argent-react-native-optimization` 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..0d053dc2 --- /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. 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 `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. The tool transparently picks hot vs cold boot: it probes the AVD's `default_boot` snapshot, restores it under a tight deadline when usable, and falls back to a full cold boot otherwise. Hot path is typically ~30s; cold path takes 2–10 min. 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 — `gesture-tap`, `gesture-swipe`, `describe`, `screenshot`, `launch-app`, `keyboard`, etc. Dispatch is automatic based on the id shape. See `argent-device-interact` for platform-neutral interaction tooling and the Android-specific gotchas section at the bottom of that skill. + +## 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. +- `describe` on Android returns a shallower tree than iOS (no accessibility-service equivalent), but covers most tap-target discovery. +- `reinstall-app` on Android always installs with `-g` so first-launch runtime permissions are pre-granted. +- To kill the emulator when you're done, run `adb -s emu kill` from a shell. diff --git a/packages/skills/skills/argent-create-flow/SKILL.md b/packages/skills/skills/argent-create-flow/SKILL.md index 5155a140..882e2a37 100644 --- a/packages/skills/skills/argent-create-flow/SKILL.md +++ b/packages/skills/skills/argent-create-flow/SKILL.md @@ -129,7 +129,7 @@ steps: You do not need the user to ask for a flow. Record one proactively when you recognize any of these patterns: -- **About to re-profile**: You completed a profiling session and are about to apply a fix and re-profile. Record the interaction steps now so the re-profile replays them identically (see `argent-react-native-profiler` and `argent-ios-profiler` skills). +- **About to re-profile**: You completed a profiling session and are about to apply a fix and re-profile. Record the interaction steps now so the re-profile replays them identically (see `argent-react-native-profiler` and `argent-native-profiler` skills). - **Repeating steps**: You have already performed a multi-step interaction sequence once and the task requires doing it again (comparison, retry, re-test). - **Complex path discovered**: You worked through a non-trivial sequence of taps/swipes/navigation to reach a desired app state. Capture it before it is lost. - **User says "again" / "one more time"**: Any request to redo what you just did is a signal to record first, then replay. diff --git a/packages/skills/skills/argent-simulator-interact/SKILL.md b/packages/skills/skills/argent-device-interact/SKILL.md similarity index 73% rename from packages/skills/skills/argent-simulator-interact/SKILL.md rename to packages/skills/skills/argent-device-interact/SKILL.md index c11a0bf3..4f992277 100644 --- a/packages/skills/skills/argent-simulator-interact/SKILL.md +++ b/packages/skills/skills/argent-device-interact/SKILL.md @@ -1,13 +1,19 @@ --- -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. +name: argent-device-interact +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 platform-specific caveats (Metro `adb reverse`, locked-screen describe errors, etc.), see § 9 Platform-specific notes at the bottom. + ## 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. +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-ios-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. @@ -16,7 +22,7 @@ Use `list-simulators` to find available simulators. **Pick the first result** if 1. **Always refer to tapping_rule** from your argent.md rule before tapping. 2. Before performing interactions, consider whether they can be **dispatched sequentially** - more on that in `run-sequence`. 3. **Use `gesture-swipe` for lists/scrolling**, not `gesture-custom`, unless you need non-linear movement. Consider whether you need multiple swipes, if yes - use `run-sequence`. -4. **Tap a text field before typing** — try `paste` first, fall back to `keyboard`. +4. **Tap a text field before typing** — on iOS try `paste` first then fall back to `keyboard`; on Android use `keyboard` directly (`paste` is iOS-only). 5. **Coordinates are normalized** — always 0.0–1.0, not pixels. 6. **For native iOS app navigation, prefer `describe` first.** It works on any screen without app restart. Do not navigate from screenshots on regular in-app screens unless `describe` failed to expose a reliable target. Use `native-describe-screen` only when you need app-scoped UIKit properties. @@ -42,35 +48,35 @@ Common schemes: `messages://`, `settings://`, `maps://?q=`, `tel://", "text": "Hello, world!" } ``` -Tap the field first, then paste. Fall back to `keyboard` if it doesn't work. +Tap the field first, then paste. Fall back to `keyboard` if it doesn't work. On Android the call is rejected by the capability gate ("Tool 'paste' is not supported on android") — use `keyboard` directly. ### keyboard — Type text or press special keys @@ -196,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` | --- @@ -270,3 +277,18 @@ Tap a known button, then scroll down: ``` Stops on the first error and returns partial results. + +--- + +## 9. Platform-specific notes + +### Android + +- **Metro reachability**: run `adb reverse tcp:8081 tcp:8081` on the device before the RN app starts, or Metro won't be reachable from the device. See `argent-metro-debugger` for the full workflow. Re-run if the device restarts. +- **First-launch permission prompts**: `reinstall-app` on Android always installs with `-g` so runtime permissions are pre-granted on first launch — no flag to pass. +- **Locked screen / secure surfaces**: `describe` throws a clear error if it 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. + +### iOS + +_(no iOS-only gotchas collected here yet — add them as they come up)_ diff --git a/packages/skills/skills/argent-simulator-interact/references/gesture-examples.md b/packages/skills/skills/argent-device-interact/references/gesture-examples.md similarity index 100% rename from packages/skills/skills/argent-simulator-interact/references/gesture-examples.md rename to packages/skills/skills/argent-device-interact/references/gesture-examples.md diff --git a/packages/skills/skills/argent-simulator-setup/SKILL.md b/packages/skills/skills/argent-ios-simulator-setup/SKILL.md similarity index 73% rename from packages/skills/skills/argent-simulator-setup/SKILL.md rename to packages/skills/skills/argent-ios-simulator-setup/SKILL.md index e4fdafa6..68786b56 100644 --- a/packages/skills/skills/argent-simulator-setup/SKILL.md +++ b/packages/skills/skills/argent-ios-simulator-setup/SKILL.md @@ -1,5 +1,5 @@ --- -name: argent-simulator-setup +name: argent-ios-simulator-setup description: Set up and connect to an iOS simulator using argent MCP tools. Use when starting a new session, booting a simulator, getting a simulator UDID, or before any simulator interaction task. --- @@ -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-metro-debugger/SKILL.md b/packages/skills/skills/argent-metro-debugger/SKILL.md index 11884309..cf17f9d6 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 +``` + +`` 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 -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-ios-profiler/SKILL.md b/packages/skills/skills/argent-native-profiler/SKILL.md similarity index 60% rename from packages/skills/skills/argent-ios-profiler/SKILL.md rename to packages/skills/skills/argent-native-profiler/SKILL.md index 0678ab9f..01f777b1 100644 --- a/packages/skills/skills/argent-ios-profiler/SKILL.md +++ b/packages/skills/skills/argent-native-profiler/SKILL.md @@ -1,23 +1,28 @@ --- -name: argent-ios-profiler -description: Native iOS profiling for CPU hotspots, UI hangs, and memory leaks via xctrace. Use when diagnosing native-level performance issues on iOS simulators or devices. +name: argent-native-profiler +description: Native profiling for CPU hotspots, UI hangs, and memory leaks. Currently iOS-only (xctrace-backed); Android support (Perfetto/simpleperf) is on the roadmap. Use when diagnosing native-level performance issues. --- -## 1. Tool Overview +## 1. Tools -| Tool | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `ios-profiler-start` | Start xctrace recording on a booted simulator or device. Captures CPU, hangs, and leaks. Optional: `app_process`, `template_path`. | -| `ios-profiler-stop` | Stop xctrace, export trace data to XML files (timestamped, persist on disk). | -| `ios-profiler-analyze` | Parse exported XML and return structured bottleneck payload (CPU hotspots, UI hangs, leaks). | -| `profiler-stack-query` | Drill into parsed data: hang stacks, function callers, thread breakdown, leak details. | -| `profiler-load` | List and reload previous trace sessions from disk for re-investigation. | +- `native-profiler-start` — start profiling on a booted device. iOS: xctrace recording for CPU, hangs, and leaks. +- `native-profiler-stop` — stop the profiler and export trace data to timestamped XML files. +- `native-profiler-analyze` — parse exported trace data and return a structured bottleneck payload. +- `profiler-stack-query` — drill into parsed data: hang stacks, function callers, thread breakdown, leak details. +- `profiler-load` — list and reload previous trace sessions from disk for re-investigation. --- -## 2. Investigation Patterns +## 2. Platform Support -After `ios-profiler-analyze` surfaces findings, use `profiler-stack-query` to drill into root causes: +- **iOS**: Fully supported. Backend: Xcode Instruments via `xctrace` on a booted simulator or connected device. Requires Xcode command-line tools on PATH. +- **Android**: Not yet implemented. An Android backend (Perfetto or simpleperf via `adb`) is planned; today `native-profiler-start` rejects Android serials with a clear "iOS-only for now" error. + +--- + +## 3. Investigation Patterns + +After `native-profiler-analyze` surfaces findings, use `profiler-stack-query` to drill into root causes: - **Hang detected** → `profiler-stack-query` mode=`hang_stacks` for full native call chains → mode=`function_callers` for the suspected function → read native source. - **CPU hotspot** → `profiler-stack-query` mode=`thread_breakdown` for per-thread distribution → mode=`function_callers` for the dominant function. @@ -27,20 +32,20 @@ After presenting findings, ask the user whether to investigate further, implemen **Tip:** For reproducible before/after comparisons, record the interaction sequence as a flow using the `argent-create-flow` skill before the first profiling run. Replay with `flow-execute` on subsequent runs to eliminate interaction variance. -> **Note:** The `argent-react-native-profiler` instructs to start iOS profiling automatically alongside React profiling. This skill's workflow and investigation patterns apply in both cases. +> **Note:** The `argent-react-native-profiler` instructs to start native profiling automatically alongside React profiling. This skill's workflow and investigation patterns apply in both cases. --- -## 3. Workflow +## 4. Workflow **Complete all steps in order — do not break mid-flow.** ### Step 0: Ensure the target app is running -The `ios-profiler-start` tool **auto-detects** the running app on the simulator. +The `native-profiler-start` tool **auto-detects** the running app on the device. You do not need to derive `app_process` manually — just make sure the app is launched. -1. If the app is already running on the simulator, skip to Step 1 (do not pass `app_process`). +1. If the app is already running on the device, skip to Step 1 (do not pass `app_process`). 2. If the app is not running, use `launch-app` with the correct bundle ID first. 3. Only pass `app_process` explicitly if the tool reports multiple running user apps and you need to disambiguate. @@ -48,16 +53,16 @@ You do not need to derive `app_process` manually — just make sure the app is l ### Step 1: Start recording -Call `ios-profiler-start` with `device_id` (simulator UDID). The tool auto-detects the running app and saves the trace to `/tmp/argent-profiler-cwd/` with a timestamped filename. -Let the user interact with the app or drive interaction via simulator tools (see `argent-simulator-interact` skill). +Call `native-profiler-start` with `device_id` (iOS UDID; Android not yet supported). The tool auto-detects the running app and saves the trace to `/tmp/argent-profiler-cwd/` with a timestamped filename. +Let the user interact with the app or drive interaction via simulator tools (see `argent-device-interact` skill). ### Step 2: Stop and export -Call `ios-profiler-stop` with `device_id`. This sends SIGINT to xctrace, waits for trace packaging, and exports CPU, hangs, and leaks data to XML. Check `exportDiagnostics` in the response for any export warnings. +Call `native-profiler-stop` with `device_id`. On iOS this sends SIGINT to xctrace, waits for trace packaging, and exports CPU, hangs, and leaks data to XML. Check `exportDiagnostics` in the response for any export warnings. ### Step 3: Analyze -Call `ios-profiler-analyze` with `device_id`. Returns a markdown report with bottlenecks categorized as CPU hotspots, UI hangs, or memory leaks, sorted by severity. +Call `native-profiler-analyze` with `device_id`. Returns a markdown report with bottlenecks categorized as CPU hotspots, UI hangs, or memory leaks, sorted by severity. ### Step 4: Present findings and ask about next steps @@ -72,12 +77,12 @@ Use `profiler-stack-query` to investigate specific findings. See §3 Investigati To revisit a previous trace: 1. Call `profiler-load` mode=`list` to see available sessions. -2. Call `profiler-load` mode=`load_instruments` session_id=`` device_id=`` to re-parse the XML files. +2. Call `profiler-load` mode=`load_native` session_id=`` device_id=`` to re-parse the XML files. 3. Use `profiler-stack-query` to investigate the reloaded data. --- -## 4. Understanding Results +## 5. Understanding Results Bottlenecks are categorized by severity: @@ -92,12 +97,10 @@ Each bottleneck type indicates a different class of problem: --- -## 5. Important Caveats +## 6. Important Caveats - **Simulator vs device**: Simulator profiling reflects host Mac performance, not real device hardware. Use device profiling for accurate CPU timings and memory behavior. -- **xctrace availability**: Requires Xcode command-line tools installed. Verify with `xcrun xctrace version`. +- **xctrace availability (iOS)**: Requires Xcode command-line tools installed. Verify with `xcrun xctrace version`. - **Profiler overhead**: xctrace instrumentation adds CPU load. If `JSLexer`, `JSONEmitter`, or Hermes runtime internals dominate the JS thread in CPU hotspot results, those reflect profiler overhead — not app work. Discount those entries when evaluating findings. - **Run-to-run variance**: Small fluctuations in CPU percentages between runs are normal. Treat only consistent directional changes (across 2+ runs or >15% delta) as actionable signal. - **Live data variability**: If the app fetches live API data, different responses between runs change rendering workload independently of code changes. Note when data-dependent screens show variance. - ---- 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..c7f992ee 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 @@ -39,23 +39,27 @@ Do NOT default to `npx react-native start` or `npx react-native run-ios` without 1. **Projects with flavors or custom configs**: Use project-specific start script if present (e.g. `npm run start:local`), and start Metro **before** running the app. -### 1.3 Run the iOS App +### 1.3 Run the App In a **separate** terminal (Metro keeps running in the first): -**Use the project's custom build/run script if one exists** (e.g. `npm run ios`, `yarn ios:debug`). Only fall back to the default if no custom scripts are defined: +**Use the project's custom build/run script if one exists** (e.g. `npm run ios`, `npm run android`, `yarn ios:debug`). Only fall back to the default if no custom scripts are defined: ```bash -npx react-native run-ios +npx react-native run-ios # iOS +npx react-native run-android # Android ``` -Optional: specify device or simulator, e.g. `npx react-native run-ios --simulator="iPhone 16"`. +Optional: specify the target device, e.g. `npx react-native run-ios --simulator="iPhone 16"` or `npx react-native run-android --deviceId=`. + +**Android only**: after install, run `adb -s reverse tcp:8081 tcp:8081` so the emulator/device can reach Metro on your host. Repeat if the device restarts or adb drops. **Agent checklist:** - [ ] 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 the device isn't booted: use `boot-device` with the iOS `udid` or Android `avdName`. Refer to the `argent-ios-simulator-setup` / `argent-android-emulator-setup` skill. +- [ ] Android: `adb -s reverse tcp:8081 tcp:8081` done. --- @@ -131,20 +135,21 @@ 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 UDID or Android serial — one device) | +| Stop all simulator servers | `stop-all-simulator-servers` tool (iOS + Android) | -For full simulator setup workflow, refer to the `argent-simulator-setup` skill. +For full simulator setup workflow, refer to the `argent-ios-simulator-setup` skill. --- @@ -210,8 +215,9 @@ 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 | +| Run Android app | `npx react-native run-android` | +| 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 | @@ -220,6 +226,7 @@ If the user's intent is ambiguous (run existing tests, write new tests, or find | Inspect React component tree | `debugger-component-tree` tool | | Run JS in app | `debugger-evaluate` tool | | iOS native logs | `npx react-native log-ios` | +| Android native logs | `npx react-native log-android` or `adb -s logcat` | | Clean + reinstall (nuclear) | See §3.1 step 3 | --- @@ -228,8 +235,8 @@ If the user's intent is ambiguous (run existing tests, write new tests, or find | Skill | When to use | | ------------------------------ | ------------------------------------------------------------------------------- | -| `argent-simulator-setup` | Initial simulator boot and connection setup | -| `argent-simulator-interact` | Tapping, swiping, typing, hardware buttons, gestures on the simulator | +| `argent-ios-simulator-setup` | Initial simulator boot and connection setup | +| `argent-device-interact` | Tapping, swiping, typing, hardware buttons, gestures on the simulator/emulator | | `argent-metro-debugger` | Full Metro CDP debugging: component inspection, console logs, JS evaluation | | `argent-react-native-profiler` | Profiling performance, finding re-render issues, CPU hotspots | | `argent-test-ui-flow` | Interactive UI testing with automatic screenshot verification after each action | diff --git a/packages/skills/skills/argent-react-native-optimization/SKILL.md b/packages/skills/skills/argent-react-native-optimization/SKILL.md index afc6f913..38a1913c 100644 --- a/packages/skills/skills/argent-react-native-optimization/SKILL.md +++ b/packages/skills/skills/argent-react-native-optimization/SKILL.md @@ -45,13 +45,13 @@ See [references/semantic-checklist.md](references/semantic-checklist.md) for ful 1. Load `argent-react-native-profiler` skill, start dual profiling 2. Exercise key user flows (navigate screens the user specified, or all major flows) -3. Analyze with `react-profiler-analyze` + `ios-profiler-analyze` + `profiler-combined-report` +3. Analyze with `react-profiler-analyze` + `native-profiler-analyze` + `profiler-combined-report` 4. Cross-reference profiling results with Phase 1–2 findings 5. Fix highest-impact issues. Re-profile after architectural changes; batch mechanical fixes. If a recorded flow breaks after a fix (e.g., UI layout changed), follow `argent-create-flow` skill to repair the flow rather than silently discarding it. ### Phase 4: Verify no regressions -Navigate every screen and UI flow within scope, confirm each renders without errors. If no scope was specified, verify the entire app — cover all reachable screens via `argent-simulator-interact`. Use `debugger-log-registry` to check for runtime errors and take screenshots to check for red/yellow error screens. Check for regressions introduced by fixes (e.g., fewer re-renders but higher CPU, or new jank in a different screen). Main agent only. +Navigate every screen and UI flow within scope, confirm each renders without errors. If no scope was specified, verify the entire app — cover all reachable screens via `argent-device-interact`. Use `debugger-log-registry` to check for runtime errors and take screenshots to check for red/yellow error screens. Check for regressions introduced by fixes (e.g., fewer re-renders but higher CPU, or new jank in a different screen). Main agent only. ## App-wide optimization diff --git a/packages/skills/skills/argent-react-native-profiler/SKILL.md b/packages/skills/skills/argent-react-native-profiler/SKILL.md index 8e930a5e..9076beb1 100644 --- a/packages/skills/skills/argent-react-native-profiler/SKILL.md +++ b/packages/skills/skills/argent-react-native-profiler/SKILL.md @@ -26,10 +26,10 @@ This skill is complementary to `argent-react-native-optimization`, not a replace | `profiler-cpu-query` | Targeted CPU investigation: top functions, time-windowed CPU, call trees, per-component CPU. | | `profiler-commit-query` | Targeted commit investigation: by component, time range, commit index, or cascade tree. | | `profiler-stack-query` | iOS Instruments drill-down: hang stacks, function callers, thread breakdown, leak details. | -| `profiler-combined-report` | Cross-correlated report when both React Profiler and iOS Instruments ran in parallel. | +| `profiler-combined-report` | Cross-correlated report when both React Profiler and native profiler ran in parallel. | | `profiler-load` | List and reload previous profiling sessions from disk for re-investigation with query tools. | -For native iOS profiling (CPU hotspots, UI hangs, memory leaks), see the `argent-ios-profiler` skill. +For native profiling (CPU hotspots, UI hangs, memory leaks), see the `argent-native-profiler` skill. --- @@ -37,8 +37,8 @@ For native iOS profiling (CPU hotspots, UI hangs, memory leaks), see the `argent Follow these rules throughout the profiling workflow: -- Start `react-profiler-start` and `ios-profiler-start` in parallel (two tool calls in one message). Both need `device_id`; use the same UDID for both so their data can be correlated later. This gives best coverage. -- If the user only wants iOS-only, use the `argent-ios-profiler` skill workflow. Only skip `ios-profiler-start` if the user has **already explicitly said** they don't want native profiling in this session +- Start `react-profiler-start` and `native-profiler-start` in parallel (two tool calls in one message). Both need `device_id`; use the same UDID for both so their data can be correlated later. This gives best coverage. +- If the user only wants native profiling, use the `argent-native-profiler` skill workflow. Only skip `native-profiler-start` if the user has **already explicitly said** they don't want native profiling in this session ### After analysis: ask about next steps @@ -56,7 +56,7 @@ When drilling down, chain query tool calls based on what you find: - A hot commit -> `profiler-commit-query` mode=`by_index` to see all components -> `profiler-cpu-query` mode=`component_cpu` for the slowest one -> `profiler-cpu-query` mode=`call_tree` for the hot function -> read the source file -> propose a fix. - A memory leak -> `profiler-stack-query` mode=`leak_stacks` to identify the responsible module -> read the native source if actionable. -- An iOS hang -> `profiler-stack-query` mode=`hang_stacks` to get the native call chain -> correlate with React commit timing. +- A native hang -> `profiler-stack-query` mode=`hang_stacks` to get the native call chain -> correlate with React commit timing. ### After fixes: always re-profile @@ -80,7 +80,7 @@ When profiling requires a specific interaction sequence (scroll a list, navigate Mind the react-native and ios-native profiler selection mentioned above when starting the session and start the tools. **Save `startedAtEpochMs` from the response** — you will need it for annotation offsets. Every subsequent profiler/query call in this session must use the same `device_id`. Before beginning, define lightweight success criteria with the user: which metric matters most (e.g., `totalRenderMs`, specific commit duration, render count for a component) and what threshold would be meaningful. This anchors later evaluation. On success: -- if user asked you to perform the profiling, determine how to profile yourself using tools described in `argent-simulator-interact` skill. +- if user asked you to perform the profiling, determine how to profile yourself using tools described in `argent-device-interact` skill. - if the user stated they wish to perform the interaction themselves — suggest what interaction to perform (e.g. "scroll the list", "switch tabs") and wait for their reply. If you received information about **existing profiling session** being owned by another agent: - if session is marked as "stale", you may overtake it without prompting the user for allowance @@ -92,7 +92,7 @@ After each `gesture-tap` or `gesture-swipe` call, record an annotation using the ### Step 2: Stop and collect -Call `react-profiler-stop` **and** `ios-profiler-stop` in parallel. Only skip `ios-profiler-stop` if you did not start it in Step 1. Note `duration_ms` and `fiber_renders_captured`. +Call `react-profiler-stop` **and** `native-profiler-stop` in parallel. Only skip `native-profiler-stop` if you did not start it in Step 1. Note `duration_ms` and `fiber_renders_captured`. If `fiber_renders_captured: 0`, warn the user — React commit data may be missing. ### Step 3: Analyze @@ -101,7 +101,7 @@ Call `react-profiler-analyze` with `port`, `device_id`, `project_root`, `platfor If you performed interactions using `gesture-tap`/`gesture-swipe`, pass `annotations` to mark when each action occurred. Each annotation's `offsetMs` must be computed as `tapTimestampMs - startedAtEpochMs`, where `tapTimestampMs` is the `timestampMs` returned by the gesture-tap/gesture-swipe tool and `startedAtEpochMs` was returned by `react-profiler-start`. Do **not** use `Date.now()` for this calculation — only server-side timestamps from the tool return values. -If dual profiling, also call `ios-profiler-analyze`, then **you must** call `profiler-combined-report` for the cross-correlated view — do not skip this step when both profilers ran; the combined report surfaces correlations that individual reports miss. +If dual profiling, also call `native-profiler-analyze`, then **you must** call `profiler-combined-report` for the cross-correlated view — do not skip this step when both profilers ran; the combined report surfaces correlations that individual reports miss. The analyze report includes **CPU hotspots per commit** — showing exactly which JS functions ran during each slow React commit. Raw data is saved to disk automatically for later reload. @@ -132,7 +132,7 @@ If you profiled multiple scenarios and need to revisit earlier data: 1. Call `profiler-load` mode=`list` to see all saved sessions with timestamps (the list now also shows Runtime / Device / Metro bundle columns to help identify the right session). 2. Call `profiler-load` mode=`load_react` session_id=`` device_id=`` to reload React data. `device_id` scopes the reload into the `port:device_id` cache slot. -3. Call `profiler-load` mode=`load_instruments` session_id=`` device_id=`` to reload iOS data. +3. Call `profiler-load` mode=`load_native` session_id=`` device_id=`` to reload native profiler data. 4. Query tools now operate on the reloaded session data — **pass the same `device_id` you loaded with**, otherwise they will miss the cache. This is useful for before/after comparisons: profile, fix, re-profile, then reload the original session to compare metrics side by side. diff --git a/packages/skills/skills/argent-react-native-profiler/references/diagnostic-tools.md b/packages/skills/skills/argent-react-native-profiler/references/diagnostic-tools.md index 039046de..091312c9 100644 --- a/packages/skills/skills/argent-react-native-profiler/references/diagnostic-tools.md +++ b/packages/skills/skills/argent-react-native-profiler/references/diagnostic-tools.md @@ -32,7 +32,7 @@ Call `debugger-log-registry`. Returns a summary with entry counts by level, mess These require a completed profiling session (`react-profiler-stop` + `react-profiler-analyze`). -## CPU query (replaces react-profiler-cpu-summary) +## CPU query ```json { "port": 8081, "device_id": "", "mode": "top_functions", "top_n": 15 } @@ -64,7 +64,7 @@ Call `profiler-commit-query`. Modes: { "device_id": "", "mode": "hang_stacks", "hang_index": 0 } ``` -Call `profiler-stack-query` after `ios-profiler-analyze`. Modes: +Call `profiler-stack-query` after `native-profiler-analyze`. Modes: - `hang_stacks` — full CPU context during a specific hang. - `function_callers` — who calls a specific native `function_name`. @@ -89,6 +89,6 @@ Call `profiler-load`. Modes: - `list` — show all saved profiling sessions (React + iOS) in `/tmp/argent-profiler-cwd/`. - `load_react` — reload a React profiler session by `session_id` + `device_id`. Populates the `port:device_id`-keyed in-memory cache for `profiler-cpu-query` and `profiler-commit-query` (which must be called with the same `device_id` afterward). -- `load_instruments` — re-parse iOS Instruments XML by `session_id` and `device_id`. Populates session for `profiler-stack-query`. +- `load_native` — re-parse native profiler XML by `session_id` and `device_id`. Populates session for `profiler-stack-query`. Use this to revisit an earlier profiling session without re-profiling. Each `react-profiler-analyze` run saves raw data with a unique timestamp. diff --git a/packages/skills/skills/argent-test-ui-flow/SKILL.md b/packages/skills/skills/argent-test-ui-flow/SKILL.md index 94bc399d..93839840 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-ios-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 -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. @@ -66,7 +77,7 @@ Steps: ## Tips -- **Use `paste` for text entry** — faster and more reliable than key-by-key `keyboard`. +- **Use `paste` for text entry on iOS** — faster and more reliable than key-by-key `keyboard`. `paste` is iOS-only; on Android use `keyboard` instead. - **Use `gesture-custom` for long-press** context menus (800ms hold). - **Report clearly**: state what you expected, what you saw, and the verdict. - **Coordinate estimation**: center = 0.5, 0.5; top-third ~ 0.2; bottom-third ~ 0.8. @@ -75,10 +86,11 @@ 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-device-interact` | Tool usage for tapping, swiping, typing (iOS + Android) | +| `argent-ios-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 | diff --git a/packages/tool-server/package.json b/packages/tool-server/package.json index c4ba6bd8..b5c3abc3 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.6.1", - "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/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index 9b3ac913..bd160bd6 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -4,6 +4,7 @@ import { promisify } from "node:util"; import { execFile, ChildProcess } from "node:child_process"; import { TypedEventEmitter, + type DeviceInfo, type ServiceBlueprint, type ServiceInstance, type ServiceEvents, @@ -14,6 +15,24 @@ const execFileAsync = promisify(execFile); export const AX_SERVICE_NAMESPACE = "AXService"; +// Same DeviceInfo-via-options pattern as the other iOS-only blueprints. +type AxServiceFactoryOptions = Record & { device: DeviceInfo }; + +/** + * Build the `ServiceRef` for the AX service keyed by an already-resolved + * `DeviceInfo`. The factory's iOS-only check uses the caller's classification + * rather than running its own. + */ +export function axServiceRef(device: DeviceInfo): { + urn: string; + options: AxServiceFactoryOptions; +} { + return { + urn: `${AX_SERVICE_NAMESPACE}:${device.id}`, + options: { device }, + }; +} + export interface AXDescribeElement { label?: string; frame?: { x: number; y: number; width: number; height: number }; @@ -157,8 +176,13 @@ function spawnDaemon(udid: string, socketPath: string): Promise { } }); + // Defense-in-depth: a missing udid here would crash the process — + // throwing inside an async listener bypasses promise rejection and + // bubbles up as `uncaughtException`, which the tool-server treats as + // fatal. Tag with "?" instead of dereferencing. + const udidTag = typeof udid === "string" && udid.length > 0 ? udid.slice(0, 8) : "?"; proc.stderr?.on("data", (data: string) => { - process.stderr.write(`[ax-service ${udid.slice(0, 8)}] ${data}`); + process.stderr.write(`[ax-service ${udidTag}] ${data}`); }); proc.on("exit", (code) => { @@ -179,14 +203,39 @@ function spawnDaemon(udid: string, socketPath: string): Promise { }); } -export const axServiceBlueprint: ServiceBlueprint = { +export const axServiceBlueprint: ServiceBlueprint = { namespace: AX_SERVICE_NAMESPACE, - getURN(udid: string) { - return `${AX_SERVICE_NAMESPACE}:${udid}`; + getURN(device: DeviceInfo) { + return `${AX_SERVICE_NAMESPACE}:${device.id}`; }, - async factory(_deps, udid) { + async factory(_deps, _payload, options) { + const opts = options as unknown as AxServiceFactoryOptions | undefined; + if (!opts?.device) { + throw new Error( + `${AX_SERVICE_NAMESPACE}.factory requires a resolved DeviceInfo via options.device. ` + + `Use axServiceRef(device) when registering the service ref.` + ); + } + + const { device } = opts; + if (device.platform !== "ios") { + throw new Error( + `${AX_SERVICE_NAMESPACE} is iOS-only. The target '${device.id}' classifies as Android — describe falls back to uiautomator on Android, which does not need this service.` + ); + } + // Reject before spawning. An undefined `device.id` slips through when an + // inner tool is invoked via a wrapper that doesn't re-validate the inner + // schema. Without this guard `getSocketPath(undefined).slice` would crash + // and `udid.slice` in the stderr handler below would later be fatal. + if (typeof device.id !== "string" || device.id.length === 0) { + throw new Error( + `${AX_SERVICE_NAMESPACE}.factory requires a non-empty device.id; got ${JSON.stringify(device.id)}.` + ); + } + + const udid = device.id; const socketPath = getSocketPath(udid); const events = new TypedEventEmitter(); diff --git a/packages/tool-server/src/blueprints/ios-profiler-session.ts b/packages/tool-server/src/blueprints/ios-profiler-session.ts deleted file mode 100644 index a30e8903..00000000 --- a/packages/tool-server/src/blueprints/ios-profiler-session.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { TypedEventEmitter, type ServiceBlueprint, type ServiceEvents } from "@argent/registry"; -import type { ChildProcess } from "child_process"; -import type { CpuSample, UiHang, MemoryLeak, CpuHotspot } from "../utils/ios-profiler/types"; -import { waitForChildExit } from "../utils/ios-profiler/lifecycle"; - -export const IOS_PROFILER_SESSION_NAMESPACE = "IosProfilerSession"; - -export interface IosProfilerParsedData { - cpuSamples: CpuSample[]; - uiHangs: UiHang[]; - cpuHotspots: CpuHotspot[]; - memoryLeaks: MemoryLeak[]; -} - -export interface IosProfilerSessionApi { - deviceId: string; - appProcess: string | null; - xctracePid: number | null; - xctraceProcess: ChildProcess | null; - traceFile: string | null; - exportedFiles: Record | null; - profilingActive: boolean; - wallClockStartMs: number | null; - parsedData: IosProfilerParsedData | null; - recordingTimeout: NodeJS.Timeout | null; - recordingTimedOut: boolean; - recordingExitedUnexpectedly: boolean; - lastExitInfo: { code: number | null; signal: string | null } | null; -} - -// Discard semantics on dispose: registry teardown only fires from process -// shutdown, where any in-flight xctrace recording is being abandoned. Skip the -// SIGINT finalise grace (that is the explicit `ios-profiler-stop` contract) -// and SIGKILL straight away so shutdown is not held up. The partial .trace on -// disk is left in place. -const DISPOSE_REAP_MS = 1_000; - -export const iosInstrumentsSessionBlueprint: ServiceBlueprint = { - namespace: IOS_PROFILER_SESSION_NAMESPACE, - - getURN(deviceId: string) { - return `${IOS_PROFILER_SESSION_NAMESPACE}:${deviceId}`; - }, - - async factory(_deps, _payload) { - const state: IosProfilerSessionApi = { - deviceId: _payload, - appProcess: null, - xctracePid: null, - xctraceProcess: null, - traceFile: null, - exportedFiles: null, - profilingActive: false, - wallClockStartMs: null, - parsedData: null, - recordingTimeout: null, - recordingTimedOut: false, - recordingExitedUnexpectedly: false, - lastExitInfo: null, - }; - - const events = new TypedEventEmitter(); - - return { - api: state, - dispose: async () => { - if (state.recordingTimeout) { - clearTimeout(state.recordingTimeout); - state.recordingTimeout = null; - } - const child = state.xctraceProcess; - if (state.profilingActive && child) { - try { - child.kill("SIGKILL"); - } catch { - // already dead - } - await waitForChildExit(child, DISPOSE_REAP_MS); - state.profilingActive = false; - state.xctracePid = null; - state.xctraceProcess = null; - } - }, - events, - }; - }, -}; diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 54e14575..e442cde5 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -4,13 +4,30 @@ import * as path from "node:path"; import * as readline from "node:readline"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { TypedEventEmitter, type ServiceBlueprint, type ServiceEvents } from "@argent/registry"; +import { + TypedEventEmitter, + type DeviceInfo, + type ServiceBlueprint, + type ServiceEvents, +} from "@argent/registry"; import { bootstrapDylibPath } from "@argent/native-devtools-ios"; const execFileAsync = promisify(execFile); export const NATIVE_DEVTOOLS_NAMESPACE = "NativeDevtools"; +type NativeDevtoolsFactoryOptions = Record & { device: DeviceInfo }; + +export function nativeDevtoolsRef(device: DeviceInfo): { + urn: string; + options: NativeDevtoolsFactoryOptions; +} { + return { + urn: `${NATIVE_DEVTOOLS_NAMESPACE}:${device.id}`, + options: { device }, + }; +} + export interface NetworkEvent { method: string; params: unknown; @@ -207,14 +224,30 @@ async function listRunningUIKitApplicationBundleIds(udid: string): Promise = { +export const nativeDevtoolsBlueprint: ServiceBlueprint = { namespace: NATIVE_DEVTOOLS_NAMESPACE, - getURN(udid: string) { - return `${NATIVE_DEVTOOLS_NAMESPACE}:${udid}`; + getURN(device: DeviceInfo) { + return `${NATIVE_DEVTOOLS_NAMESPACE}:${device.id}`; }, - async factory(_deps, udid) { + async factory(_deps, _payload, options) { + const opts = options as unknown as NativeDevtoolsFactoryOptions | undefined; + if (!opts?.device) { + throw new Error( + `${NATIVE_DEVTOOLS_NAMESPACE}.factory requires a resolved DeviceInfo via options.device. ` + + `Use nativeDevtoolsRef(device) when registering the service ref, or pass { device } when calling resolveService directly.` + ); + } + + const { device } = opts; + if (device.platform !== "ios") { + throw new Error( + `${NATIVE_DEVTOOLS_NAMESPACE} is iOS-only. The target '${device.id}' classifies as Android — native-devtools tools (native-describe-screen, native-find-views, etc.) only drive iOS simulators. Pick an iOS udid from list-devices.` + ); + } + + const udid = device.id; const socketPath = getNativeDevtoolsSocketPath(udid); const MAX_LOG_ENTRIES = 1000; const connections = new Map(); diff --git a/packages/tool-server/src/blueprints/native-profiler-session.ts b/packages/tool-server/src/blueprints/native-profiler-session.ts new file mode 100644 index 00000000..2f82ac33 --- /dev/null +++ b/packages/tool-server/src/blueprints/native-profiler-session.ts @@ -0,0 +1,132 @@ +import { + ServiceRef, + TypedEventEmitter, + type DeviceInfo, + type ServiceBlueprint, + type ServiceEvents, +} from "@argent/registry"; +import type { ChildProcess } from "child_process"; +import type { CpuSample, UiHang, MemoryLeak, CpuHotspot } from "../utils/ios-profiler/types"; +import { waitForChildExit } from "../utils/ios-profiler/lifecycle"; + +// The tools that consume this session are cross-platform in name +// (`native-profiler-*`), but today the only backend is xctrace on iOS. When +// Perfetto / simpleperf land, this namespace keeps the same URN shape — +// `NativeProfilerSession:` — and the factory branches on +// the caller-provided `device.platform` to build either the iOS or Android +// backend without reclassifying. +export const NATIVE_PROFILER_SESSION_NAMESPACE = "NativeProfilerSession"; + +// Same shape as the other DeviceInfo-routed blueprints: caller threads through +// `options.device`, registry-side URN payload is just `device.id`. +type NativeProfilerSessionFactoryOptions = Record & { device: DeviceInfo }; + +export function nativeProfilerSessionRef(device: DeviceInfo): ServiceRef { + return { + urn: `${NATIVE_PROFILER_SESSION_NAMESPACE}:${device.id}`, + options: { device }, + }; +} + +export interface NativeProfilerParsedData { + cpuSamples: CpuSample[]; + uiHangs: UiHang[]; + cpuHotspots: CpuHotspot[]; + memoryLeaks: MemoryLeak[]; +} + +export interface NativeProfilerSessionApi { + deviceId: string; + appProcess: string | null; + xctracePid: number | null; + xctraceProcess: ChildProcess | null; + traceFile: string | null; + exportedFiles: Record | null; + profilingActive: boolean; + wallClockStartMs: number | null; + parsedData: NativeProfilerParsedData | null; + recordingTimeout: NodeJS.Timeout | null; + recordingTimedOut: boolean; + recordingExitedUnexpectedly: boolean; + lastExitInfo: { code: number | null; signal: string | null } | null; +} + +// Discard semantics on dispose: registry teardown only fires from process +// shutdown, where any in-flight xctrace recording is being abandoned. Skip the +// SIGINT finalise grace (that is the explicit `native-profiler-stop` contract) +// and SIGKILL straight away so shutdown is not held up. The partial .trace on +// disk is left in place. +const DISPOSE_REAP_MS = 1_000; + +export const nativeProfilerSessionBlueprint: ServiceBlueprint< + NativeProfilerSessionApi, + DeviceInfo +> = { + namespace: NATIVE_PROFILER_SESSION_NAMESPACE, + + getURN(device: DeviceInfo) { + return `${NATIVE_PROFILER_SESSION_NAMESPACE}:${device.id}`; + }, + + // DeviceInfo travels via options (registry URN-payload channel is string-only). + async factory(_deps, _payload, options) { + const opts = options as unknown as NativeProfilerSessionFactoryOptions | undefined; + if (!opts?.device) { + throw new Error( + `${NATIVE_PROFILER_SESSION_NAMESPACE}.factory requires a resolved DeviceInfo via options.device. ` + + `Use nativeProfilerSessionRef(device) when registering the service ref.` + ); + } + const { device } = opts; + // Android backend (Perfetto / simpleperf) is not implemented yet; reject + // early so an Android serial gets a clear "not yet" message instead of an + // opaque xctrace failure deeper in. + if (device.platform !== "ios") { + throw new Error( + `${NATIVE_PROFILER_SESSION_NAMESPACE} currently supports iOS only (xctrace-backed). ` + + `The target '${device.id}' classifies as Android — Android profiling (Perfetto/simpleperf) is on the roadmap. ` + + `Pick an iOS udid from list-devices for now.` + ); + } + const state: NativeProfilerSessionApi = { + deviceId: device.id, + appProcess: null, + xctracePid: null, + xctraceProcess: null, + traceFile: null, + exportedFiles: null, + profilingActive: false, + wallClockStartMs: null, + parsedData: null, + recordingTimeout: null, + recordingTimedOut: false, + recordingExitedUnexpectedly: false, + lastExitInfo: null, + }; + + const events = new TypedEventEmitter(); + + return { + api: state, + dispose: async () => { + if (state.recordingTimeout) { + clearTimeout(state.recordingTimeout); + state.recordingTimeout = null; + } + const child = state.xctraceProcess; + if (state.profilingActive && child) { + try { + child.kill("SIGKILL"); + } catch { + // already dead + } + await waitForChildExit(child, DISPOSE_REAP_MS); + state.profilingActive = false; + state.xctracePid = null; + state.xctraceProcess = null; + } + }, + events, + }; + }, +}; diff --git a/packages/tool-server/src/blueprints/simulator-server.ts b/packages/tool-server/src/blueprints/simulator-server.ts index 63a68ec6..ae1b08ef 100644 --- a/packages/tool-server/src/blueprints/simulator-server.ts +++ b/packages/tool-server/src/blueprints/simulator-server.ts @@ -2,15 +2,39 @@ import { spawn, ChildProcess } from "node:child_process"; import * as readline from "node:readline"; import { TypedEventEmitter, + type DeviceInfo, type ServiceBlueprint, type ServiceInstance, type ServiceEvents, } from "@argent/registry"; import { simulatorServerBinaryPath, simulatorServerBinaryDir } from "@argent/native-devtools-ios"; import { ensureAutomationEnabled } from "./ax-service"; +import { ensureDep } from "../utils/check-deps"; export const SIMULATOR_SERVER_NAMESPACE = "SimulatorServer"; +// The registry's `ServiceRef.options` is typed as `Record`, +// so the factory options must be assignable to it (intersection adds the +// implicit string index signature that an `interface { device: DeviceInfo }` +// alone wouldn't satisfy). +type SimulatorServerFactoryOptions = Record & { device: DeviceInfo }; + +/** + * Build the `ServiceRef` for the simulator-server keyed by an already-resolved + * `DeviceInfo`. Tool `services()` callbacks should call this rather than + * hand-building the URN string, so the blueprint factory always receives the + * device through the registry's `options` channel and never has to reclassify. + */ +export function simulatorServerRef(device: DeviceInfo): { + urn: string; + options: SimulatorServerFactoryOptions; +} { + return { + urn: `${SIMULATOR_SERVER_NAMESPACE}:${device.id}`, + options: { device }, + }; +} + const getPaths = () => { const BINARY_PATH = simulatorServerBinaryPath(); const BINARY_DIR = simulatorServerBinaryDir(); @@ -26,14 +50,17 @@ export interface SimulatorServerApi { pressKey(direction: "Down" | "Up", keyCode: number): void; } -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(); return new Promise((resolve, reject) => { - const args = ["ios", "--id", udid]; + const args = [platform, "--id", udid]; const proc = spawn(BINARY_PATH, args, { cwd: BINARY_DIR, @@ -88,8 +115,13 @@ function spawnSimulatorServerProcess(udid: string): Promise<{ } }); + // Defense-in-depth: a missing udid here would crash the process — + // throwing inside an async listener bypasses promise rejection and + // bubbles up as `uncaughtException`, which the tool-server treats as + // fatal. Tag with "?" instead of dereferencing. + const udidTag = typeof udid === "string" && udid.length > 0 ? udid.slice(0, 8) : "?"; proc.stderr?.on("data", (data: Buffer) => { - process.stderr.write(`[sim ${udid.slice(0, 8)}] ${data}`); + process.stderr.write(`[sim ${udidTag}] ${data}`); }); proc.on("exit", () => { @@ -109,19 +141,41 @@ function spawnSimulatorServerProcess(udid: string): Promise<{ }); } -export const simulatorServerBlueprint: ServiceBlueprint = { +export const simulatorServerBlueprint: ServiceBlueprint = { namespace: SIMULATOR_SERVER_NAMESPACE, - getURN(udid: string) { - return `${SIMULATOR_SERVER_NAMESPACE}:${udid}`; + getURN(device: DeviceInfo) { + return `${SIMULATOR_SERVER_NAMESPACE}:${device.id}`; }, - async factory(_deps, payload) { - const udid = payload; - // Enable accessibility automation before any app is launched so that apps - // start with their AX server running. If this is called after apps are already - // running (e.g. a pre-booted simulator), those apps won't pick up the flag - // until restarted — but new launches will work correctly. - await ensureAutomationEnabled(udid).catch(() => {}); - const { proc, apiUrl, streamUrl } = await spawnSimulatorServerProcess(udid); + async factory(_deps, _payload, options) { + const opts = options as unknown as SimulatorServerFactoryOptions | undefined; + if (!opts?.device) { + throw new Error( + `${SIMULATOR_SERVER_NAMESPACE}.factory requires a resolved DeviceInfo via options.device. ` + + `Use simulatorServerRef(device) when registering the service ref, or pass { device } when calling resolveService directly.` + ); + } + + const { device } = opts; + if (typeof device.id !== "string" || device.id.length === 0) { + throw new Error( + `${SIMULATOR_SERVER_NAMESPACE}.factory requires a non-empty device.id; got ${JSON.stringify(device.id)}.` + ); + } + + if (device.platform === "ios") { + // Enable accessibility automation before any app is launched so that apps + // start with their AX server running. If this is called after apps are already + // running (e.g. a pre-booted simulator), those apps won't pick up the flag + // until restarted — but new launches will work correctly. + await ensureAutomationEnabled(device.id).catch(() => {}); + } else { + await ensureDep("adb"); + } + + const { proc, apiUrl, streamUrl } = await spawnSimulatorServerProcess( + device.id, + device.platform + ); const events = new TypedEventEmitter(); diff --git a/packages/tool-server/src/http.ts b/packages/tool-server/src/http.ts index f45b292a..4f695e10 100644 --- a/packages/tool-server/src/http.ts +++ b/packages/tool-server/src/http.ts @@ -2,11 +2,11 @@ import express, { Request, Response } from "express"; import type { Registry } from "@argent/registry"; import { ToolNotFoundError } from "@argent/registry"; import { createIdleTimer } from "./utils/idle-timer"; +import { DependencyMissingError, ensureDeps } from "./utils/check-deps"; import { formatErrorForAgent } from "./utils/format-error"; import { getUpdateState, isUpdateNoteSuppressed, suppressUpdateNote } from "./utils/update-checker"; import { buildUpdateNote } from "./update-utils"; import { createPreviewRouter } from "./preview"; -import { DependencyMissingError, ensureDeps } from "./utils/check-deps"; import { assertSupported, NotImplementedOnPlatformError, @@ -16,6 +16,16 @@ import { resolveDevice } from "./utils/device-info"; const AUTO_SUPPRESS_MS = 30 * 60 * 1000; // 30 minutes +function findDependencyMissing(err: unknown): DependencyMissingError | null { + let current: unknown = err; + // Bounded to avoid pathological cycles; in practice the chain is ≤ 2 links. + for (let depth = 0; depth < 8 && current instanceof Error; depth++) { + if (current instanceof DependencyMissingError) return current; + current = current.cause; + } + return null; +} + // ── HTTP app ──────────────────────────────────────────────────────── export interface HttpAppOptions { @@ -121,9 +131,21 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt // "unsupported on android" error, not a misleading "xcrun missing". // Cross-platform tools double-check inside their dispatch helper, so // non-HTTP callers (run-sequence, flow-run) are also covered. - if (def.capability && parsedData && typeof parsedData.udid === "string") { + // + // Tools spell the device parameter two ways — `udid` (legacy iOS-only + // tools and gestures) and `device_id` (debugger / profiler / network + // tools). Honour both so an Android serial reaching an iOS-only + // device_id-tool is rejected at the gate instead of falling through + // to the deeper blueprint error (which surfaces as a generic 500). + const deviceArg = + typeof parsedData?.udid === "string" + ? parsedData.udid + : typeof parsedData?.device_id === "string" + ? parsedData.device_id + : null; + if (def.capability && deviceArg) { try { - const device = resolveDevice(parsedData.udid); + const device = resolveDevice(deviceArg); assertSupported(def.id, def.capability, device); } catch (err) { if (err instanceof UnsupportedOperationError) { @@ -177,8 +199,13 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt res.status(404).json({ error: err.message }); return; } - if (err instanceof DependencyMissingError) { - res.status(424).json({ error: err.message, missing: err.missing }); + // Walk the cause chain so a registry ToolExecutionError wrapping + // a DependencyMissingError still maps cleanly to 424 instead of a + // generic 500. Tools that ensureDep() inside execute() bypass the + // global preflight; this is their fall-back surface. + const depErr = findDependencyMissing(err); + if (depErr) { + res.status(424).json({ error: depErr.message, missing: depErr.missing }); return; } if (err instanceof UnsupportedOperationError) { diff --git a/packages/tool-server/src/preview.ts b/packages/tool-server/src/preview.ts index ed36b374..5fb37777 100644 --- a/packages/tool-server/src/preview.ts +++ b/packages/tool-server/src/preview.ts @@ -3,8 +3,9 @@ import path from "node:path"; import type { Request, Response, Router } from "express"; import express from "express"; import type { Registry } from "@argent/registry"; -import { SIMULATOR_SERVER_NAMESPACE, type SimulatorServerApi } from "./blueprints/simulator-server"; -import { listSimulatorsTool } from "./tools/simulator/list-simulators"; +import { simulatorServerRef, type SimulatorServerApi } from "./blueprints/simulator-server"; +import { resolveDevice } from "./utils/device-info"; +import { listDevicesTool } from "./tools/devices/list-devices"; function findUiHtml(): string | null { // Candidate paths (first match wins): @@ -34,15 +35,43 @@ export function createPreviewRouter(registry: Registry): Router { router.get("/simulators", async (_req: Request, res: Response) => { try { const data = await registry.invokeTool<{ - simulators: Array<{ - udid: string; - name: string; - state: string; - runtime: string; - isAvailable: boolean; - }>; - }>(listSimulatorsTool.id); - res.json(data); + devices: Array< + | { platform: "ios"; udid: string; name: string; state: string; runtime: string } + | { + platform: "android"; + serial: string; + state: string; + avdName?: string; + model?: string; + sdkLevel?: number | null; + } + >; + }>(listDevicesTool.id); + // The preview UI keys off `udid` and `state === "Booted"`, which are + // iOS terminology. Map Android serials to the same shape so the same + // dropdown can target both platforms — `simulator-server/:udid` already + // accepts Android serials via `resolveDevice(udid)`. + const simulators = data.devices.map((d) => { + if (d.platform === "ios") { + return { + udid: d.udid, + name: d.name, + state: d.state, + runtime: d.runtime, + isAvailable: true, + platform: "ios" as const, + }; + } + return { + udid: d.serial, + name: d.avdName ?? d.model ?? d.serial, + state: d.state === "device" ? "Booted" : d.state, + runtime: d.sdkLevel != null ? `Android API ${d.sdkLevel}` : "Android", + isAvailable: true, + platform: "android" as const, + }; + }); + res.json({ simulators }); } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); } @@ -51,9 +80,8 @@ export function createPreviewRouter(registry: Registry): Router { router.get("/simulator-server/:udid", async (req: Request, res: Response) => { const udid = req.params.udid!; try { - const api = await registry.resolveService( - `${SIMULATOR_SERVER_NAMESPACE}:${udid}` - ); + const { urn, options } = simulatorServerRef(resolveDevice(udid)); + const api = await registry.resolveService(urn, options); res.json({ udid, apiUrl: api.apiUrl, diff --git a/packages/tool-server/src/tools/button/index.ts b/packages/tool-server/src/tools/button/index.ts index 78db2f42..04e9c4b5 100644 --- a/packages/tool-server/src/tools/button/index.ts +++ b/packages/tool-server/src/tools/button/index.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type ButtonResult, type ButtonServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +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("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"), @@ -13,26 +15,36 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + pressed: string; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; -export const buttonTool: ToolDefinition = { +export const buttonTool: ToolDefinition = { 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. + description: `Press a device hardware button (iOS simulator or Android emulator). Sends Down then Up events automatically. +Supported buttons depend on the platform: home, back, power, volumeUp, volumeDown, appSwitch, actionButton — buttons not present on the target platform are rejected with a clear error from the backend. +Use when you need to trigger hardware button events. Returns { pressed: buttonName }. -Fails if the simulator server is not running for the given UDID.`, +Fails if the simulator-server / emulator backend is not reachable for the given device.`, zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "button", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + sendCommand(api, { + cmd: "button", + direction: "Down", + button: params.button, + }); + await sleep(50); + sendCommand(api, { cmd: "button", direction: "Up", button: params.button }); + return { pressed: params.button }; + }, }; diff --git a/packages/tool-server/src/tools/button/platforms/android.ts b/packages/tool-server/src/tools/button/platforms/android.ts deleted file mode 100644 index 6e9ed347..00000000 --- a/packages/tool-server/src/tools/button/platforms/android.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { ButtonParams, ButtonResult, ButtonServices } from "./ios"; - -export const androidImpl: PlatformImpl = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "button", - platform: "android", - hint: "Use `adb shell input keyevent `: home=3, back=4, appSwitch=187, volumeUp=24, volumeDown=25, power=26.", - }); - }, -}; diff --git a/packages/tool-server/src/tools/button/platforms/ios.ts b/packages/tool-server/src/tools/button/platforms/ios.ts deleted file mode 100644 index 8f20b501..00000000 --- a/packages/tool-server/src/tools/button/platforms/ios.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sendCommand } from "../../../utils/simulator-client"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -export type ButtonName = - | "home" - | "back" - | "power" - | "volumeUp" - | "volumeDown" - | "appSwitch" - | "actionButton"; - -export interface ButtonParams { - udid: string; - button: ButtonName; -} - -export interface ButtonResult { - pressed: string; -} - -export interface ButtonServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl = { - handler: async (services, params) => { - const api = services.simulatorServer; - sendCommand(api, { - cmd: "button", - direction: "Down", - button: params.button, - }); - await sleep(50); - sendCommand(api, { cmd: "button", direction: "Up", button: params.button }); - return { pressed: params.button }; - }, -}; 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 90b20a63..4616afe9 100644 --- a/packages/tool-server/src/tools/debugger/debugger-component-tree.ts +++ b/packages/tool-server/src/tools/debugger/debugger-component-tree.ts @@ -485,7 +485,11 @@ export function buildTextTree( const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), onScreenOnly: z .boolean() .default(true) @@ -519,7 +523,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 argent-react-native-app-workflow skill. diff --git a/packages/tool-server/src/tools/debugger/debugger-connect.ts b/packages/tool-server/src/tools/debugger/debugger-connect.ts index 57e5b872..badff801 100644 --- a/packages/tool-server/src/tools/debugger/debugger-connect.ts +++ b/packages/tool-server/src/tools/debugger/debugger-connect.ts @@ -7,7 +7,7 @@ const zodSchema = z.object({ device_id: z .string() .describe( - "iOS Simulator UDID (logicalDeviceId). The returned logicalDeviceId must be forwarded as device_id to all subsequent debugger-* and profiler-* calls to pin them to this device." + "Device logicalDeviceId (iOS simulator UDID or Android logicalDeviceId returned by Metro). The returned logicalDeviceId must be forwarded as device_id to all subsequent debugger-* and profiler-* calls to pin them to this device." ), }); diff --git a/packages/tool-server/src/tools/debugger/debugger-evaluate.ts b/packages/tool-server/src/tools/debugger/debugger-evaluate.ts index af47e40c..507af3ae 100644 --- a/packages/tool-server/src/tools/debugger/debugger-evaluate.ts +++ b/packages/tool-server/src/tools/debugger/debugger-evaluate.ts @@ -4,7 +4,11 @@ import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger" const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), expression: z.string().describe("JavaScript expression to evaluate in the app runtime"), }); diff --git a/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts b/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts index 5122cd9b..0ab93ef7 100644 --- a/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts +++ b/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts @@ -112,7 +112,11 @@ export function filterInspectItems(items: InspectItem[], includeSkipped = false) const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), x: z.coerce.number().describe("Logical X coordinate on device screen"), y: z.coerce.number().describe("Logical Y coordinate on device screen"), contextLines: z.coerce diff --git a/packages/tool-server/src/tools/debugger/debugger-log-registry.ts b/packages/tool-server/src/tools/debugger/debugger-log-registry.ts index 5438a3c7..7398df66 100644 --- a/packages/tool-server/src/tools/debugger/debugger-log-registry.ts +++ b/packages/tool-server/src/tools/debugger/debugger-log-registry.ts @@ -12,7 +12,11 @@ interface LogRegistryResponse extends LogStats { const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), }); export const debuggerLogRegistryTool: ToolDefinition< diff --git a/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts b/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts index 406f5b47..f134504b 100644 --- a/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts +++ b/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts @@ -5,7 +5,11 @@ import { DISABLE_LOGBOX_SCRIPT } from "../../utils/debugger/scripts/disable-logb const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), }); export const debuggerReloadMetroTool: ToolDefinition< diff --git a/packages/tool-server/src/tools/debugger/debugger-status.ts b/packages/tool-server/src/tools/debugger/debugger-status.ts index 304392bb..f348ec40 100644 --- a/packages/tool-server/src/tools/debugger/debugger-status.ts +++ b/packages/tool-server/src/tools/debugger/debugger-status.ts @@ -4,7 +4,11 @@ import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger" const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), }); export const debuggerStatusTool: ToolDefinition< diff --git a/packages/tool-server/src/tools/describe/contract.ts b/packages/tool-server/src/tools/describe/contract.ts index ea803687..0e70a0f5 100644 --- a/packages/tool-server/src/tools/describe/contract.ts +++ b/packages/tool-server/src/tools/describe/contract.ts @@ -16,6 +16,18 @@ export interface DescribeNode { label?: string; identifier?: string; value?: string; + // Interactivity flags surfaced by the Android uiautomator dump. iOS + // consumers leave these unset; adding them as optional avoids breaking + // existing payloads. `scrollHidden` counts children that fell outside an + // ancestor scroll's clip rect — the agent should swipe before tapping. + clickable?: boolean; + longClickable?: boolean; + scrollable?: boolean; + checkable?: boolean; + checked?: boolean; + disabled?: boolean; + password?: boolean; + scrollHidden?: number; } export const describeNodeSchema: z.ZodType = z.lazy(() => @@ -27,13 +39,26 @@ export const describeNodeSchema: z.ZodType = z.lazy(() => label: z.string().optional(), identifier: z.string().optional(), value: z.string().optional(), + clickable: z.boolean().optional(), + longClickable: z.boolean().optional(), + scrollable: z.boolean().optional(), + checkable: z.boolean().optional(), + checked: z.boolean().optional(), + disabled: z.boolean().optional(), + password: z.boolean().optional(), + scrollHidden: z.number().int().nonnegative().optional(), }) .passthrough() ); export interface DescribeResult { tree: DescribeNode; - source: "ax-service" | "native-devtools"; + // "ax-service" / "native-devtools" come from iOS; "uiautomator" is the + // Android branch's underlying provider. Agents that branch on `source` + // (e.g. to decide whether to also call `native-find-views` for a richer + // tree) need to distinguish the Android case from an iOS native-devtools + // fallback — which the previous shared label hid. + source: "ax-service" | "native-devtools" | "uiautomator"; should_restart?: boolean; } diff --git a/packages/tool-server/src/tools/describe/index.ts b/packages/tool-server/src/tools/describe/index.ts index a7a6925e..575ef31d 100644 --- a/packages/tool-server/src/tools/describe/index.ts +++ b/packages/tool-server/src/tools/describe/index.ts @@ -2,18 +2,21 @@ import { z } from "zod"; import type { Registry, ToolCapability, ToolDefinition } from "@argent/registry"; import type { DescribeResult } from "./contract"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { describeIos, iosRequires } from "./platforms/ios"; import { describeAndroid, androidRequires } from "./platforms/android"; +import { iosRequires, describeIos } from "./platforms/ios"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() .optional() .describe( - "Optional app bundle ID. Used as a target hint when the AX-service returns no elements " + + "Optional app bundle ID. Used as a target hint on iOS 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." + "If omitted, the fallback auto-detects the frontmost connected app. Ignored on Android." ), }); @@ -21,6 +24,7 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; // `describe` doesn't fit dispatchByPlatform's standard service-typed @@ -31,9 +35,9 @@ const capability: ToolCapability = { export function createDescribeTool(registry: Registry): ToolDefinition { 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 accessibility element tree for the current screen. +On iOS, uses the AXRuntime accessibility service to inspect whatever is currently visible — including +system dialogs, permission prompts, and any foreground app content. On Android, runs \`uiautomator dump\`. 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. @@ -45,20 +49,24 @@ and simulator-server touch input. 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 today; Android (uiautomator) is on the roadmap.`, +use native-describe-screen with an explicit bundleId instead (iOS only). +For React Native apps, debugger-component-tree returns React component names with tap coordinates.`, alwaysLoad: true, - searchHint: "ios accessibility element tree discovery tap coordinates", + searchHint: "accessibility element tree ui hierarchy tap coordinates ios android", zodSchema, capability, services: () => ({}), - execute: dispatchByPlatform, Params, DescribeResult>({ + execute: dispatchByPlatform< + Record, + Record, + Params, + DescribeResult + >({ toolId: "describe", capability, ios: { requires: iosRequires, - handler: (_services, params) => describeIos(registry, params), + handler: (_services, params, device) => describeIos(registry, device, params), }, android: { requires: androidRequires, diff --git a/packages/tool-server/src/tools/describe/platforms/android.ts b/packages/tool-server/src/tools/describe/platforms/android.ts deleted file mode 100644 index 2d72ba5a..00000000 --- a/packages/tool-server/src/tools/describe/platforms/android.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ToolDependency } from "@argent/registry"; -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { DescribeResult } from "../contract"; - -export const androidRequires: ToolDependency[] = ["adb"]; - -export async function describeAndroid(_udid: string, _bundleId?: string): Promise { - throw new NotImplementedOnPlatformError({ - toolId: "describe", - platform: "android", - hint: - "Wire `adb -s exec-out uiautomator dump` + a uiautomator XML parser; " + - "return a DescribeNode tree matching the iOS shape. Handle keyguard / DRM " + - "overlays which refuse capture, and use a per-call random temp file under " + - "/data/local/tmp/ to avoid races between concurrent describes.", - }); -} diff --git a/packages/tool-server/src/tools/describe/platforms/android/index.ts b/packages/tool-server/src/tools/describe/platforms/android/index.ts new file mode 100644 index 00000000..b4aa56f2 --- /dev/null +++ b/packages/tool-server/src/tools/describe/platforms/android/index.ts @@ -0,0 +1,43 @@ +import type { ToolDependency } from "@argent/registry"; +import type { DescribeResult } from "../../contract"; +import { adbExecOutBinary } from "../../../../utils/adb"; +import { getAndroidScreenSize } from "../../../../utils/android-screen"; +import { parseUiAutomatorDump } from "./uiautomator-parser"; + +export const androidRequires: ToolDependency[] = ["adb"]; + +export async function describeAndroid(udid: string, _bundleId?: 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`; + // `--compressed` strips nodes that `isImportantForAccessibility()` would skip + // (decorative wrappers, RN SVG sub-paths, bounds-less Compose group containers) + // while preserving every text label, content-desc, clickable, and resource-id + // an accessibility service would surface — i.e. exactly what the agent contract + // already cares about. Empirically cuts a Bluesky thread dump from 65 KB → 23 KB + // and 181 → 64 nodes with zero loss of useful info. + // Trailing `; rm -f` (not `&& rm -f`) so the cleanup fires even when `dump` + // or `cat` fails — keyguard/MFA flaps used to leak a dump file per attempt. + const [size, rawBuf] = await Promise.all([ + getAndroidScreenSize(udid), + adbExecOutBinary( + udid, + `uiautomator dump --compressed ${dumpPath} >/dev/null && cat ${dumpPath}; rm -f ${dumpPath}`, + { timeoutMs: 20_000 } + ), + ]); + const raw = rawBuf.toString("utf-8"); + const trimmed = raw.trim(); + if (/^ERROR:/i.test(trimmed) || (!trimmed.includes("; + 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*/, ""); + // The attr block must allow `>` inside quoted attribute values: XML §2.4 + // requires only `<` and `&` to be escaped, so `text="A > B"` is legal and + // does occur in real uiautomator dumps. The previous `[^<>]*?` rejected + // those tags entirely and silently reparented the dropped subtree onto the + // root. The unquoted-character class also excludes `/` so the trailing + // `\s*(\/?)` still recognises self-closing tags (``); without + // that exclusion the `/` was consumed into the attr block and self-closing + // tags were mistaken for openers, leaking unbalanced nesting downstream. + // `s` flag keeps newline tolerance for builds that wrap dumps at ~1 KB + // boundaries. + const tagRe = /<(\/?)([A-Za-z_][\w.-]*)((?:"[^"]*"|'[^']*'|[^"'/<>])*?)\s*(\/?)>/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) { + // Guard against a stray `` with no matching opener: an unguarded + // pop on an empty stack returns undefined, but worse — it leaves the + // next opening tag treated as a root candidate. The root-once guard + // below also handles the related case where a malformed dump emits + // multiple top-level elements. + if (stack.length > 0) 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 if (root === null) { + root = node; + } else { + // Malformed input lost its stack context (typically: an extra `` + // popped the real parent). Re-attach the orphan to the existing root so + // subsequent siblings stay reachable instead of being silently dropped. + root.children.push(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; +} + +// Single-pass decoder. Chained per-entity `.replace` calls double-decode: +// `&lt;` (correct XML encoding of the literal string `<`) becomes `<` +// after the first pass and then `<` after the second — wrong per XML §4.6. +// A single regex alternation scans left-to-right and consumes each match +// once, so a decoded `&` produced by one step never feeds the next step. +function decodeXmlEntities(s: string): string { + return s.replace( + /&(?:#x([0-9A-Fa-f]+)|#(\d+)|(amp|lt|gt|quot|apos));/g, + (match, hex, dec, name) => { + if (hex) return safeFromCodePoint(parseInt(hex, 16)); + if (dec) return safeFromCodePoint(parseInt(dec, 10)); + switch (name) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + case "apos": + return "'"; + default: + return match; + } + } + ); +} + +function safeFromCodePoint(n: number): string { + // Numeric character references can encode values outside the valid Unicode + // range (or surrogate halves). Fall back to an empty string rather than + // throwing — the parsed tree is still usable without the broken glyph. + if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return ""; + if (n >= 0xd800 && n <= 0xdfff) return ""; + try { + return String.fromCodePoint(n); + } catch { + return ""; + } +} + +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) }; +} + +/** + * Intersect a uiautomator-pixel rect with the screen rect. `parseUiAutomatorBounds` + * preserves negative origins and out-of-range corners so callers can detect + * partially-off-screen views; this helper produces the visible portion only, + * which is what `describe` needs to normalise into the [0,1] contract. + */ +export function clipBoundsToScreen( + b: { x: number; y: number; w: number; h: number }, + screenW: number, + screenH: number +): { x: number; y: number; w: number; h: number } { + if (screenW <= 0 || screenH <= 0) return { x: 0, y: 0, w: 0, h: 0 }; + const x1 = Math.max(0, Math.min(b.x, screenW)); + const y1 = Math.max(0, Math.min(b.y, screenH)); + const x2 = Math.max(0, Math.min(b.x + b.w, screenW)); + const y2 = Math.max(0, Math.min(b.y + b.h, screenH)); + 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"; +} + +// ---------- v2 interactables-only trim ------------------------------------ +// +// On the Bluesky post-thread screen the trimmer cuts the parsed tree from 64 +// nodes (`uiautomator dump --compressed`) to ~41 actionable / labelled nodes +// while preserving every clickable, every text label, every content-desc, and +// every resource-id we care about. + +const NOISY_CLASSES = new Set([ + // React Native Skia/SVG icon internals: never tappable, parent already + // carries the icon's content-desc, and a single icon can balloon a dump by + // 40+ leaf nodes. Drop the entire subtree. + "com.horcrux.svg.PathView", + "com.horcrux.svg.GroupView", + "com.horcrux.svg.SvgView", +]); + +const SYSTEM_PACKAGES = new Set([ + // Status bar / nav bar / quick settings — these exist on every dump but + // rarely matter for app-level navigation. Note we deliberately do NOT drop + // the foreground-app's own package even when the foreground IS a system app + // (settings, permission dialog), so permission prompts still surface. + "com.android.systemui", +]); + +const SYSTEM_RID_PREFIXES = [ + "android:id/navigationBarBackground", + "android:id/statusBarBackground", + "com.android.systemui:id/", +]; + +const LAYOUT_CONTAINERS = new Set([ + "android.widget.FrameLayout", + "android.widget.LinearLayout", + "android.widget.RelativeLayout", + "androidx.constraintlayout.widget.ConstraintLayout", + "androidx.coordinatorlayout.widget.CoordinatorLayout", + "android.view.ViewGroup", + // Bare android.view.View is what Compose emits when a semantics node has + // no widget mapping; treat it as a scaffold and walk through. + "android.view.View", +]); + +const SCROLL_CLASSES = new Set([ + "android.widget.ScrollView", + "android.widget.HorizontalScrollView", + "androidx.recyclerview.widget.RecyclerView", + "android.widget.ListView", +]); + +const WEBVIEW_CLASSES = new Set(["android.webkit.WebView", "android.webkit.WebViewChromium"]); + +interface PixelRect { + x: number; + y: number; + w: number; + h: number; +} + +// Internal representation kept during the trim pass. Carries pixel bounds so +// the duplicate-wrapper / scroll-clip checks can use exact equality and +// inclusion math (normalising to [0,1] first risks drift on the equality +// check and would force a denormalise-then-compare round trip). +interface UiNode { + role: string; + pixelBounds: PixelRect | null; + label?: string; + identifier?: string; + value?: string; + clickable: boolean; + longClickable: boolean; + scrollable: boolean; + checkable: boolean; + checked: boolean; + disabled: boolean; + password: boolean; + scrollHidden: number; + children: UiNode[]; +} + +interface PruneOptions { + screenW: number; + screenH: number; + includeSystem: boolean; +} + +function attrIsTrue(attrs: Record, key: string): boolean { + return attrs[key] === "true"; +} + +function isInteractive(attrs: Record): boolean { + if ( + attrIsTrue(attrs, "clickable") || + attrIsTrue(attrs, "long-clickable") || + attrIsTrue(attrs, "checkable") || + attrIsTrue(attrs, "scrollable") + ) { + return true; + } + // A focusable node only counts as interactive when it has a label — + // otherwise it's just a focus-trap on a layout wrapper. + if (attrIsTrue(attrs, "focusable") && labelOf(attrs) !== "") return true; + return false; +} + +function labelOf(attrs: Record): string { + // The DescribeNode contract surfaces the screen-reader-meaningful label and + // the user-typed text separately, so we prefer `content-desc` (the role + // description / placeholder) and let `text` come through as `value` when + // the two diverge — e.g. an EditText with content-desc="Email" + text="x@y" + // emits label="Email", value="x@y" so an agent can see both pieces. + // For nodes with only one populated, the order doesn't matter. + const cd = (attrs["content-desc"] ?? "").trim(); + if (cd) return cd; + return (attrs.text ?? "").trim(); +} + +function isVisibleRect(b: PixelRect | null, sw: number, sh: number): boolean { + if (!b) return false; + if (b.w <= 0 || b.h <= 0) return false; + if (b.x >= sw || b.y >= sh || b.x + b.w <= 0 || b.y + b.h <= 0) return false; + return true; +} + +function isSystemChrome(attrs: Record): boolean { + if (SYSTEM_PACKAGES.has(attrs.package ?? "")) return true; + const rid = attrs["resource-id"] ?? ""; + return SYSTEM_RID_PREFIXES.some((p) => rid.startsWith(p)); +} + +function rectsEqual(a: PixelRect, b: PixelRect): boolean { + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; +} + +function rectFullyOutside(kid: PixelRect, clip: PixelRect): boolean { + return ( + kid.x + kid.w <= clip.x || + kid.x >= clip.x + clip.w || + kid.y + kid.h <= clip.y || + kid.y >= clip.y + clip.h + ); +} + +/** + * Concatenate every non-empty `text` / `content-desc` reachable from `parsed`, + * deduped and capped, so a clickable container without its own label can + * borrow its descendants' labels (the "row-as-tap-target" pattern: tapping a + * profile cell where the cell itself has no content-desc but contains the + * user's name + handle + bio). + */ +function descendantText(parsed: ParsedXmlNode, maxChars = 120): string { + const parts: string[] = []; + const seen = new Set(); + const stack: ParsedXmlNode[] = [parsed]; + while (stack.length > 0) { + const x = stack.pop()!; + for (const k of ["text", "content-desc"] as const) { + const v = (x.attrs[k] ?? "").trim(); + if (v && !seen.has(v)) { + seen.add(v); + parts.push(v); + } + } + // Push in reverse so we pop in original document order. + for (let i = x.children.length - 1; i >= 0; i--) { + const c = x.children[i]!; + if (c.tag === "node") stack.push(c); + } + } + const s = parts.join(" / "); + return s.length > maxChars ? s.slice(0, maxChars) + "…" : s; +} + +function makeUiNode( + attrs: Record, + role: string, + pixelBounds: PixelRect | null, + label: string, + children: UiNode[] +): UiNode { + const out: UiNode = { + role, + pixelBounds, + children, + clickable: attrIsTrue(attrs, "clickable"), + longClickable: attrIsTrue(attrs, "long-clickable"), + scrollable: attrIsTrue(attrs, "scrollable"), + checkable: attrIsTrue(attrs, "checkable"), + checked: attrIsTrue(attrs, "checked"), + disabled: attrs.enabled === "false", + password: attrIsTrue(attrs, "password"), + scrollHidden: 0, + }; + if (label) out.label = label; + const rid = attrs["resource-id"]; + if (rid) out.identifier = rid; + // Match the existing contract: when text and the user-facing label diverge, + // expose `text` separately so consumers can still read e.g. an EditText's + // current value while the placeholder lives in `content-desc`/`label`. + // Skip for password fields — `label` has already been redacted to + // "[password]" but the raw `attrs.text` still holds the secret, and + // `text !== label` would otherwise smuggle it through under `value`. + if (!out.password) { + const text = (attrs.text ?? "").trim(); + if (text && text !== label) out.value = text; + } + return out; +} + +/** + * Apply the v2 trim rules to `parsed`'s subtree, returning the list of + * UiNodes that should appear in the output where `parsed` currently sits. + * Returns: + * [] — node fully dropped (nothing replaces it) + * [n] — node kept, possibly with collapsed/aggregated form + * [a,b,...] — node was a passthrough wrapper; its kept children inline + * directly into the parent's child list + */ +function pruneSubtree(root: ParsedXmlNode, opts: PruneOptions): UiNode[] { + // Iterative post-order walk. Each frame records the scrollClip the parent + // wants this node to enforce on its own children — see the python + // reference for why the filter fires at the parent of the clipped node, + // not at the scroll itself. + type Frame = { parsed: ParsedXmlNode; scrollClip: PixelRect | null; visited: boolean }; + const stack: Frame[] = [{ parsed: root, scrollClip: null, visited: false }]; + const outputs = new Map(); + + while (stack.length > 0) { + const top = stack[stack.length - 1]!; + if (!top.visited) { + top.visited = true; + const attrs = top.parsed.attrs; + const cls = attrs.class ?? ""; + const myBounds = parseUiAutomatorBounds(attrs.bounds ?? ""); + const isScroll = SCROLL_CLASSES.has(cls) || attrIsTrue(attrs, "scrollable"); + // Children inherit either MY bounds (if I'm a scroll) or whatever clip + // I was handed. They'll filter their own kids against this rect. + const childClip = isScroll && myBounds ? myBounds : top.scrollClip; + for (let i = top.parsed.children.length - 1; i >= 0; i--) { + const c = top.parsed.children[i]!; + if (c.tag === "node") { + stack.push({ parsed: c, scrollClip: childClip, visited: false }); + } + } + } else { + outputs.set(top.parsed, computeNodeOutput(top.parsed, top.scrollClip, outputs, opts)); + stack.pop(); + } + } + return outputs.get(root) ?? []; +} + +function computeNodeOutput( + parsed: ParsedXmlNode, + scrollClip: PixelRect | null, + outputs: Map, + opts: PruneOptions +): UiNode[] { + const attrs = parsed.attrs; + const cls = attrs.class ?? ""; + + if (NOISY_CLASSES.has(cls)) return []; + if (!opts.includeSystem && isSystemChrome(attrs)) return []; + + const bounds = parseUiAutomatorBounds(attrs.bounds ?? ""); + const visible = isVisibleRect(bounds, opts.screenW, opts.screenH); + + // Collect child outputs, filtering against my inherited scroll clip. + let keptChildren: UiNode[] = []; + let hiddenInScroll = 0; + for (const c of parsed.children) { + if (c.tag !== "node") continue; + const kids = outputs.get(c); + if (!kids) continue; + for (const kid of kids) { + if (scrollClip && kid.pixelBounds && rectFullyOutside(kid.pixelBounds, scrollClip)) { + hiddenInScroll += 1; + continue; + } + keptChildren.push(kid); + } + } + + // WebView: DOM is opaque to uiautomator, so emit a single sentinel leaf + // and discard the (always misleading) accessibility scaffold underneath. + if (WEBVIEW_CLASSES.has(cls)) { + if (!visible) return []; + const own = labelOf(attrs); + return [makeUiNode(attrs, "WebView", bounds, "[web-view] " + (own || "(no label)"), [])]; + } + + const interactive = isInteractive(attrs); + let label = labelOf(attrs); + + // Decorative ImageView (no clickable, no label) — drop, pass through any + // surviving descendants. Most decorative images have zero kept children + // and the entire branch evaporates. + if (cls.endsWith(".ImageView") && !interactive && !label) { + return keptChildren; + } + + // Layout container with no own info — pass children through. With + // --compressed dumps this is what flattens FrameLayout > LinearLayout > + // ConstraintLayout chains down to their actual content. + if (LAYOUT_CONTAINERS.has(cls) && !interactive && !label) { + return keptChildren; + } + + // Off-screen and contributes nothing → drop entirely. + if (!visible && keptChildren.length === 0) return []; + + // Compound clickable: borrow descendant labels so the agent has something + // to read. Skips pure scrollables (their descendants are usually a screen + // worth of text). + if ( + (attrIsTrue(attrs, "clickable") || attrIsTrue(attrs, "long-clickable")) && + !label && + keptChildren.length > 0 + ) { + const agg = descendantText(parsed); + if (agg) label = agg; + } + + // Duplicate-wrapper collapse: a clickable parent whose only kept descendant + // is also clickable and has identical bounds is just an extra layer of the + // same tap target. Keep the inner (typically more specific) node. + if (interactive && bounds && keptChildren.length === 1) { + const c = keptChildren[0]!; + if (c.clickable && c.pixelBounds && rectsEqual(c.pixelBounds, bounds)) { + return [c]; + } + } + + // If I have a label, drop child Text nodes whose label is already a + // substring of mine. Stops the agent seeing both "Like (634 likes)" and a + // bare "634" inside it as separate items. + if (interactive && label) { + const lower = label.toLowerCase(); + keptChildren = keptChildren.filter( + (c) => + !( + c.role === "StaticText" && + c.label && + lower.includes(c.label.toLowerCase()) && + !c.clickable + ) + ); + } + + // Password fields: keep the ref but never leak the value. + if (attrIsTrue(attrs, "password")) { + label = "[password]"; + } + + const node = makeUiNode(attrs, deriveUiAutomatorRole(cls), bounds, label, keptChildren); + if (hiddenInScroll > 0) node.scrollHidden = hiddenInScroll; + return [node]; +} + +/** + * Lower a UiNode tree to the public DescribeNode contract. Iterative post-order + * so that very deep trees (RN screens stacked ~30 levels deep, ListView item + * recyclers, etc.) don't risk a stack overflow even though the trim has + * already shortened most chains. + */ +function describeFromUiTree(root: UiNode, sw: number, sh: number): DescribeNode | null { + const out = new Map(); + type S = { node: UiNode; visited: boolean }; + const stack: S[] = [{ node: root, visited: false }]; + while (stack.length > 0) { + const top = stack[stack.length - 1]!; + if (!top.visited) { + top.visited = true; + for (let i = top.node.children.length - 1; i >= 0; i--) { + stack.push({ node: top.node.children[i]!, visited: false }); + } + } else { + const childDns: DescribeNode[] = []; + for (const c of top.node.children) { + const dn = out.get(c); + if (dn) childDns.push(dn); + } + out.set(top.node, finalizeUiNode(top.node, childDns, sw, sh)); + stack.pop(); + } + } + return out.get(root) ?? null; +} + +function finalizeUiNode( + n: UiNode, + children: DescribeNode[], + sw: number, + sh: number +): DescribeNode | null { + let frame: DescribeFrame; + if (n.pixelBounds) { + // Clip the rectangle against the screen BEFORE normalising. Independently + // clamping `x` and `width` to [0,1] lets `x + width` exceed 1 (e.g. an + // off-screen item at bounds=[1090,0][1280,200] on a 1080-wide screen + // produces x=1, width≈0.176 → tap centre lands off-screen). Clipping the + // edges first guarantees the visible portion is what we normalise and the + // rect always satisfies the describe-frame contract. + const clipped = clipBoundsToScreen(n.pixelBounds, sw, sh); + frame = { + x: sw > 0 ? clipped.x / sw : 0, + y: sh > 0 ? clipped.y / sh : 0, + width: sw > 0 ? clipped.w / sw : 0, + height: sh > 0 ? clipped.h / sh : 0, + }; + } else { + // No bounds: drop empty wrappers, pass through single-child wrappers, and + // synthesise a union frame for 2+ children. Replacing the whole subtree + // with `null` silently dropped every child on Compose hierarchies that + // emit bounds-less group containers — preserved here for that case. + if (children.length === 0) return null; + if (children.length === 1) return children[0]!; + const x1 = Math.min(...children.map((c) => c.frame.x)); + const y1 = Math.min(...children.map((c) => c.frame.y)); + const x2 = Math.max(...children.map((c) => c.frame.x + c.frame.width)); + const y2 = Math.max(...children.map((c) => c.frame.y + c.frame.height)); + frame = { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) }; + } + const out: DescribeNode = { + role: n.role, + frame, + children, + }; + if (n.label) out.label = n.label; + if (n.identifier) out.identifier = n.identifier; + if (n.value) out.value = n.value; + if (n.clickable) out.clickable = true; + if (n.longClickable) out.longClickable = true; + if (n.scrollable) out.scrollable = true; + if (n.checkable) out.checkable = true; + if (n.checked) out.checked = true; + if (n.disabled) out.disabled = true; + if (n.password) out.password = true; + if (n.scrollHidden > 0) out.scrollHidden = n.scrollHidden; + return out; +} + +/** + * 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. Applies the v2 interactables-only trim defined above. + * + * `includeSystem` defaults to false: status bar / nav bar / SystemUI chrome + * is dropped because it's noise on app-level tasks. Pass `true` when working + * with a multi-window dump (uiautomator2) where systemui owns the IME or the + * notification shade. + */ +export function parseUiAutomatorDump( + rawOutput: string, + screenW: number, + screenH: number, + options: { includeSystem?: boolean } = {} +): 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 includeSystem = options.includeSystem === true; + const opts: PruneOptions = { screenW, screenH, includeSystem }; + const topChildren: DescribeNode[] = []; + for (const c of root.children) { + if (c.tag !== "node") continue; + const ui = pruneSubtree(c, opts); + for (const n of ui) { + const dn = describeFromUiTree(n, screenW, screenH); + if (dn) topChildren.push(dn); + } + } + return { + role: "Screen", + frame: { x: 0, y: 0, width: 1, height: 1 }, + children: topChildren, + }; +} diff --git a/packages/tool-server/src/tools/describe/platforms/ios.ts b/packages/tool-server/src/tools/describe/platforms/ios/index.ts similarity index 54% rename from packages/tool-server/src/tools/describe/platforms/ios.ts rename to packages/tool-server/src/tools/describe/platforms/ios/index.ts index 70c5a297..a1e12bf1 100644 --- a/packages/tool-server/src/tools/describe/platforms/ios.ts +++ b/packages/tool-server/src/tools/describe/platforms/ios/index.ts @@ -1,30 +1,31 @@ -import type { Registry, ToolDependency } from "@argent/registry"; -import type { AXServiceApi } from "../../../blueprints/ax-service"; -import { AX_SERVICE_NAMESPACE } from "../../../blueprints/ax-service"; -import type { NativeDevtoolsApi } from "../../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../../blueprints/native-devtools"; -import type { DescribeResult } from "../contract"; +import type { DeviceInfo, Registry, ToolDependency } from "@argent/registry"; +import { axServiceRef, AXServiceApi } from "../../../../blueprints/ax-service"; +import { nativeDevtoolsRef, NativeDevtoolsApi } from "../../../../blueprints/native-devtools"; +import { resolveNativeTargetApp } from "../../../../utils/native-target-app"; +import { parseNativeDescribeScreenResult } from "../../../native-devtools/native-describe-contract"; +import { DescribeResult } from "../../contract"; import { adaptAXDescribeToDescribeResult } from "./ios-ax-adapter"; import { adaptNativeDescribeToDescribeResult } from "./ios-native-adapter"; -import { parseNativeDescribeScreenResult } from "../../native-devtools/native-describe-contract"; -import { resolveNativeTargetApp } from "../../../utils/native-target-app"; export interface DescribeIosParams { - udid: string; bundleId?: string; } -// describe on iOS goes through ax-service / native-devtools, both of which -// resolve via Registry — no direct xcrun shell-out, so no `requires` here. -export const iosRequires: ToolDependency[] = []; +// describe on iOS resolves the ax-service via Registry; the blueprint factory +// shells out to `xcrun simctl spawn` (ensureAutomationEnabled + spawnDaemon). +// Without xcrun on PATH the spawn ENOENTs deep inside the factory and the +// HTTP layer returns a 500 with a raw "spawn xcrun ENOENT" message — declare +// the dep here so the preflight emits a 424 with the install hint instead, +// matching launch-app / restart-app / open-url / reinstall-app. +export const iosRequires: ToolDependency[] = ["xcrun"]; export async function describeIos( registry: Registry, + device: DeviceInfo, params: DescribeIosParams ): Promise { - const axApi = await registry.resolveService( - `${AX_SERVICE_NAMESPACE}:${params.udid}` - ); + const axRef = axServiceRef(device); + const axApi = await registry.resolveService(axRef.urn, axRef.options); const response = await axApi.describe(); const tree = adaptAXDescribeToDescribeResult(response); @@ -34,9 +35,8 @@ export async function describeIos( // AX returned zero elements — attempt native-devtools fallback try { - const nativeApi = await registry.resolveService( - `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}` - ); + const ndRef = nativeDevtoolsRef(device); + const nativeApi = await registry.resolveService(ndRef.urn, ndRef.options); const target = await resolveNativeTargetApp(nativeApi, params.bundleId); diff --git a/packages/tool-server/src/tools/describe/platforms/ios-ax-adapter.ts b/packages/tool-server/src/tools/describe/platforms/ios/ios-ax-adapter.ts similarity index 88% rename from packages/tool-server/src/tools/describe/platforms/ios-ax-adapter.ts rename to packages/tool-server/src/tools/describe/platforms/ios/ios-ax-adapter.ts index 913d9c7b..2d7e65fd 100644 --- a/packages/tool-server/src/tools/describe/platforms/ios-ax-adapter.ts +++ b/packages/tool-server/src/tools/describe/platforms/ios/ios-ax-adapter.ts @@ -1,6 +1,6 @@ +import { AXDescribeElement, AXDescribeResponse } from "../../../../blueprints/ax-service"; +import { DescribeNode, parseDescribeResult } from "../../contract"; import { mapNativeTraitsToDescribeRole } from "./ios-native-adapter"; -import { parseDescribeResult, type DescribeNode } from "../contract"; -import type { AXDescribeResponse, AXDescribeElement } from "../../../blueprints/ax-service"; function clamp01(value: number): number { return Math.min(1, Math.max(0, value)); diff --git a/packages/tool-server/src/tools/describe/platforms/ios-native-adapter.ts b/packages/tool-server/src/tools/describe/platforms/ios/ios-native-adapter.ts similarity index 92% rename from packages/tool-server/src/tools/describe/platforms/ios-native-adapter.ts rename to packages/tool-server/src/tools/describe/platforms/ios/ios-native-adapter.ts index 43ca7a10..18528815 100644 --- a/packages/tool-server/src/tools/describe/platforms/ios-native-adapter.ts +++ b/packages/tool-server/src/tools/describe/platforms/ios/ios-native-adapter.ts @@ -1,8 +1,8 @@ -import type { +import { NativeDescribeElement, NativeDescribeScreenResult, -} from "../../native-devtools/native-describe-contract"; -import { parseDescribeResult, type DescribeFrame, type DescribeNode } from "../contract"; +} from "../../../native-devtools/native-describe-contract"; +import { DescribeFrame, DescribeNode, parseDescribeResult } from "../../contract"; function clamp01(value: number): number { return Math.min(1, Math.max(0, value)); 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..4975b281 --- /dev/null +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -0,0 +1,671 @@ +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { z } from "zod"; +import type { Registry, ToolCapability, ToolDefinition } from "@argent/registry"; +import { nativeDevtoolsRef } from "../../blueprints/native-devtools"; +import { + adbShell, + checkSnapshotLoadable, + hasDefaultBootSnapshot, + listAndroidDevices, + listAvds, + resolveEmulatorOrThrow, + runAdb, + waitForBootCompleted, +} from "../../utils/adb"; +import { ensureDep } from "../../utils/check-deps"; + +const execFileAsync = promisify(execFile); + +// NOTE on mutual exclusion: `udid` and `avdName` are exactly-one — but zod's +// `.refine()` returns a ZodEffects that our Registry ToolDefinition type does +// not accept (it requires a ZodObject so the JSON Schema generator can walk +// `.shape`). The exactly-one check therefore lives inside `execute` and +// surfaces with a specific error message on the first call. We restate the +// constraint in each field's `.describe()` so MCP clients still see it in the +// generated tool docs even if their JSON-schema inspector ignores the runtime. +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`." + ), + 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 }; + +// 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, + child?: import("node:child_process").ChildProcess +): Promise { + // Preferred path: emulator console's kill command (the supported API). + // It drains pending writes — including a mid-save ram.bin on the cold path + // — before qemu exits. Generous timeout because a graceful flush of a + // multi-hundred-MB ram.bin under host memory pressure can take several + // seconds, and we'd rather wait than orphan a half-written snapshot. + if (serial) { + await runAdb(["-s", serial, "emu", "kill"], { timeoutMs: 15_000 }).catch(() => {}); + } + if (!child) return; + // Fallback for a wedged console (hypervisor stall, GPU driver reset, IO- + // thread deadlock — all leave qemu alive but deaf to `adb emu kill`). + // SIGTERM, not SIGKILL: qemu installs a SIGTERM handler that mirrors the + // console-kill flush path, so a writable snapshot stays consistent. SIGKILL + // could truncate an in-flight ram.bin write and poison the next boot. + // Fire-and-forget — if qemu ignores SIGTERM too, it is unrecoverably stuck + // and blocking our caller any longer just delays the user's next action. + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGTERM"); + } catch { + // Already gone. + } + } +} + +// Best-effort termination for an emulator that was spawned detached + unref'd +// but never registered with adb — in that state `adb emu kill` has no serial +// to target, so we must signal the ChildProcess directly. SIGTERM only +// (fire-and-forget): qemu's SIGTERM handler mirrors the console-kill flush +// path, keeping any in-progress snapshot save consistent. SIGKILL could +// truncate a mid-write ram.bin; if qemu ignores SIGTERM it is wedged past +// recovery and blocking the caller any longer is worse than walking away. +function killDetachedEmulator(child: import("node:child_process").ChildProcess): void { + try { + child.kill("SIGTERM"); + } catch { + // Already gone. + } +} + +/** + * Verify that `screencap` returns real pixel data, not an all-zero buffer. + * + * Observed failure: on a hot-boot restore, every Android-side readiness probe + * passes (`sys.boot_completed=1`, `pm path android` resolves, launcher is the + * focused window, SurfaceFlinger reports the display as enabled, `gfxinfo` + * confirms frames are rendering) yet every pixel `screencap` returns is + * `(0,0,0,0)`. The broken frame is sticky: waking the screen, dismissing + * keyguard, toggling power, swiping, launching a new activity, and capturing + * on-device (`screencap /sdcard/shot.png`) all produce the same all-zero + * output. Only a cold boot restores a working capture path. Hypothesis: + * SurfaceFlinger's host-side composite buffer is not restored with the guest + * state, so any screenshot reader sees an unhydrated framebuffer. The exact + * trigger isn't fully pinned down — fresh snapshots saved on this host do not + * reproduce it today, but the sticky-blank state has been observed after + * long-lived emulator sessions and against stale snapshots. A caller who + * trusts `booted:true` and screenshots the device gets a silently-wrong blank + * image, which is worse than a slower boot; we pay ~200 ms per hot boot to + * eliminate that failure mode entirely. + * + * Detection: run the check on-device. `screencap` writes a 16-byte header + * (width, height, format, colorspace) followed by raw RGBA pixel bytes; + * `tail -c +17` skips the header, `tr -d '\0'` drops null bytes, and + * `head -c 1 | wc -c` prints `1` if any byte survived or `0` if the stream + * past the header was entirely null. `head` short-circuits as soon as one + * non-zero byte appears, so a healthy frame costs microseconds of pixel + * inspection — no host-side decode, no allocation, no iteration we own. + * + * On detection we throw; the outer catch in `bootAndroid` kills the hot child + * and falls through to the cold path, so the serial that eventually reaches + * the caller is always usable for screenshots. + */ +async function assertScreencapAlive(serial: string): Promise { + const out = await adbShell(serial, "screencap | tail -c +17 | tr -d '\\0' | head -c 1 | wc -c", { + timeoutMs: 10_000, + }); + // Match success on "1" specifically: empty output (screencap binary missing, + // exec-out drained nothing) used to trim to "" which !== "0" and silently + // returned success — i.e. a broken capture path was reported as healthy. + // Any non-"1" reading (zero pixels OR no output at all) is a failure. + if (out.trim() !== "1") { + await killEmulatorQuietly(serial); + throw new Error( + "hot-boot composite not restored: `screencap` returned an all-zero or empty frame. " + + "Falling back to cold boot so screenshots are usable." + ); + } +} + +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 { + // 3 s per poll — a hung adb daemon on the default 30 s timeout would eat + // the whole outer stage budget in a single call. + const { stdout } = await runAdb(["devices"], { timeoutMs: 3_000 }).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 ensureDep("xcrun"); + 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"]); + const ndRef = nativeDevtoolsRef({ id: udid, platform: "ios", kind: "simulator" }); + await registry.resolveService(ndRef.urn, ndRef.options); + await execFileAsync("defaults", [ + "write", + "com.apple.iphonesimulator", + "CurrentDeviceUDID", + udid, + ]); + await execFileAsync("open", ["-a", "Simulator.app"]); + return { platform: "ios", udid, booted: true }; +} + +// Tight budget for a hot boot attempt. A successful hot boot completes well +// under 15 s on fast hardware and under ~45 s on a cold host page cache; the +// 90 s ceiling exists to bound the pathological case where snapshot load +// succeeds but the guest system_server is stuck — without this cap, a silent +// system-server hang would eat the full cold-boot budget before we retry. +const HOT_BOOT_BUDGET_MS = 90_000; + +/** + * Attempt a single boot with the supplied emulator args. Extracted from + * `bootAndroid` so the hot-boot path and the cold-boot fallback share every + * stage without diverging. The caller supplies the serialsBefore snapshot + * (captured once per `bootAndroid` invocation, *before* either attempt) + * because recomputing it between attempts would include the serial from the + * failed hot-boot child if reaping is still in flight. + */ +async function attemptBoot(params: { + avdName: string; + emulatorBinary: string; + emulatorArgs: string[]; + attemptDeadline: number; + serialsBefore: Set; + adbRegisterBudgetMs: number; + deviceReadyBudgetMs: number; + bootCompletedBudgetMs: number; +}): Promise<{ serial: string }> { + const child = spawn(params.emulatorBinary, params.emulatorArgs, { + detached: true, + stdio: "ignore", + }); + child.unref(); + + let earlyExitError: Error | null = null; + child.on("exit", (code, signal) => { + // A QEMU SIGSEGV/SIGABRT comes through as `code === null, signal !== null`. + // The previous `code !== null` guard treated those as a normal exit, so a + // hot-boot child that segfaulted on a bad ram.bin restore would hang the + // outer wait until the per-stage budget elapsed instead of failing fast. + if (signal) { + earlyExitError = new Error( + `emulator binary terminated by signal ${signal} before the device booted. ` + + `Common causes: ram.bin corruption on hot-boot restore, hypervisor crash, host OOM. ` + + `Try \`emulator -avd ${params.avdName} -verbose\` from a terminal to see the exact error.` + ); + return; + } + 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.` + ); + } + }); + // `spawn` itself can fail (ENOENT — emulator binary missing/EACCES, transient + // FS hiccup) by emitting an `error` event on the child. EventEmitter + // convention is that an unhandled `error` escapes as an uncaught exception + // that would crash the tool-server. Funnel it into the same earlyExitError + // race so the boot promise rejects with the actual cause and the in-flight + // Map entry (cleared by `bootAndroid`'s `finally`) doesn't leak. + child.on("error", (err: NodeJS.ErrnoException) => { + earlyExitError = new Error( + `Failed to spawn emulator binary (${err.code ?? "unknown"}): ${err.message}. ` + + `Verify Android SDK Emulator is installed and on PATH, then retry.` + ); + }); + + // Stage 2: wait for adb to see the new emulator. + let serial: string | null = null; + const adbDeadline = Math.min(params.attemptDeadline, Date.now() + params.adbRegisterBudgetMs); + try { + while (Date.now() < adbDeadline) { + if (earlyExitError) throw earlyExitError; + const newSerials = await listNewEmulatorSerials(params.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)); + } + } catch (err) { + killDetachedEmulator(child); + throw err; + } + if (!serial) { + if (earlyExitError) { + killDetachedEmulator(child); + throw earlyExitError; + } + killDetachedEmulator(child); + throw new Error( + `Emulator "${params.avdName}" did not register within ${params.adbRegisterBudgetMs / 1000}s. ` + + `The emulator process has been terminated.` + ); + } + + // Stage 3: wait-for-device (tcp socket up). + const stage3Racer = createEarlyExitRacer(() => earlyExitError); + try { + await Promise.race([ + runAdb(["-s", serial, "wait-for-device"], { + timeoutMs: Math.min( + params.deviceReadyBudgetMs, + Math.max(1_000, params.attemptDeadline - Date.now()) + ), + }), + stage3Racer.promise, + ]); + } catch (err) { + await killEmulatorQuietly(serial, child); + throw err instanceof Error + ? err + : new Error(`adb wait-for-device failed for ${serial}: ${String(err)}.`); + } finally { + stage3Racer.cancel(); + } + + // Stage 4: sys.boot_completed = 1. + const bootBudget = Math.max( + 5_000, + Math.min(params.bootCompletedBudgetMs, params.attemptDeadline - Date.now()) + ); + try { + await waitForBootCompleted(serial, bootBudget, { shouldAbort: () => earlyExitError }); + } catch (err) { + await killEmulatorQuietly(serial, child); + throw err instanceof Error ? err : new Error(String(err)); + } + + // Stage 5: PackageManager sanity — a snapshot restore preserves + // sys.boot_completed=1 so this is the first real proof the guest is live. + // Race against earlyExitError so a crash here surfaces with the actual + // signal/exit-code error, not a misleading "PackageManager did not respond". + const stage5Racer = createEarlyExitRacer(() => earlyExitError); + try { + await Promise.race([ + adbShell(serial, "pm path android", { timeoutMs: 10_000 }), + stage5Racer.promise, + ]); + } catch (err) { + await killEmulatorQuietly(serial, child); + if (err instanceof Error && /^emulator binary (exited|terminated)/.test(err.message)) { + throw err; + } + throw new Error( + `PackageManager did not respond on ${serial} after boot_completed. ` + + `Emulator has been terminated.` + ); + } finally { + stage5Racer.cancel(); + } + + return { serial }; +} + +// In-flight boot per AVD. Two `bootAndroid` calls for the same AVD would each +// pass the "already running" fast-path (the emulator hasn't registered yet) +// and both spawn QEMU — the second collides on the AVD's exclusive on-disk +// lock and bails after the boot deadline with a confusing "Running multiple +// emulators" error. Coalescing in-flight calls per AVD makes a duplicate call +// reuse the result of the first one (or its eventual error). +const inFlightBoots = new Map< + string, + Promise<{ + platform: "android"; + serial: string; + avdName: string; + booted: true; + }> +>(); + +/** + * Clear the in-flight boot map. Exposed for tests that intentionally abandon + * a half-started boot to assert orphan-cleanup behavior — without this hook + * the leaked promise would coalesce into the next test that targets the same + * AVD and starve it of a real spawn. + */ +export function __resetInFlightBootsForTesting(): void { + inFlightBoots.clear(); +} + +async function bootAndroid(params: { avdName: string; bootTimeoutMs: number }): Promise<{ + platform: "android"; + serial: string; + avdName: string; + booted: true; +}> { + const existing = inFlightBoots.get(params.avdName); + if (existing) return existing; + const promise = bootAndroidImpl(params).finally(() => { + inFlightBoots.delete(params.avdName); + }); + inFlightBoots.set(params.avdName, promise); + return promise; +} + +async function bootAndroidImpl(params: { avdName: string; bootTimeoutMs: number }): Promise<{ + platform: "android"; + serial: string; + avdName: string; + booted: true; +}> { + // Preflight both Android binaries up front so a missing emulator package + // surfaces as a 424 "install hint" — not a misleading "no AVDs" error from + // `listAvds()`'s empty result. `ensureDep("emulator")` consults the + // resolver, which honors `$ANDROID_HOME` in addition to PATH. + await ensureDep("adb"); + await ensureDep("emulator"); + const emulatorBinary = await resolveEmulatorOrThrow(); + const overallDeadline = Date.now() + params.bootTimeoutMs; + + // Stage 0: validate AVD exists. Past this point an empty AVD list really + // does mean "user has no AVDs" (the binary is present); the preflight ruled + // out the binary-missing case. + const avds = await listAvds(); + if (avds.length === 0) { + throw new Error( + "`emulator -list-avds` returned no AVDs. 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(", ")}.` + ); + } + + // Stage 0b: verify adb is on PATH *before* spawning the emulator, so we + // don't orphan a detached emulator process just to later throw "adb missing". + try { + await runAdb(["version"], { timeoutMs: 5_000 }); + } catch (err) { + throw new Error( + `\`adb\` is not available on PATH (${ + err instanceof Error ? err.message : String(err) + }). Install Android SDK Platform Tools before booting an emulator.` + ); + } + + // Ensure the adb daemon is running BEFORE we snapshot the serial list. + // If the daemon was down, `adb devices` returns [] — without this the + // snapshot is empty and every currently-connected emulator later looks + // "new", so the tool could hand back an unrelated emulator as "booted". + await runAdb(["start-server"], { timeoutMs: 10_000 }).catch(() => {}); + const existingDevices = await listAndroidDevices().catch(() => []); + + // Fast path: if this exact AVD is already running and ready, reuse it + // instead of spawning a second emulator that would collide on AVD locks, + // burn the full 90 s hot-boot budget in the probe + spawn failure, and + // surface a misleading "Running multiple emulators" error. + let hotBootFailureReason: string | null = null; + const alreadyRunning = existingDevices.find( + (d) => d.isEmulator && d.avdName === params.avdName && d.state === "device" + ); + if (alreadyRunning) { + // BUG GUARD — wedged-framebuffer detection on the reuse path. + // A long-running emulator can drift into the same sticky-blank + // SurfaceFlinger state that `assertScreencapAlive` defends against on a + // hot-boot restore (see its docstring): every Android-side readiness + // probe still passes, but `screencap` only returns null bytes — meaning + // the caller would silently get a serial whose screenshots are all + // black. Without this probe the fast-path returns that wedged serial + // forever and there is no way back, since `coldBoot` was removed. + // On failure the helper kills the wedged emulator; we then fall through + // to the snapshot/probe pipeline so the caller still gets a usable boot. + try { + await assertScreencapAlive(alreadyRunning.serial); + return { + platform: "android", + serial: alreadyRunning.serial, + avdName: params.avdName, + booted: true, + }; + } catch (err) { + hotBootFailureReason = `running AVD framebuffer was wedged (${ + err instanceof Error ? err.message : String(err) + }), respawning`; + // assertScreencapAlive already killed the emulator; refresh the + // existing-devices snapshot so the killed serial is included in + // serialsBefore (matching the hot-boot catch refresh below) and the + // upcoming spawn's "new serial" diff stays correct. + const refreshed = await listAndroidDevices().catch(() => existingDevices); + existingDevices.splice(0, existingDevices.length, ...refreshed); + } + } + const serialsBefore = new Set(existingDevices.map((d) => d.serial)); + + // Decide whether to try a hot boot: only if a default_boot snapshot exists + // on disk AND the emulator's own `-check-snapshot-loadable` probe says the + // metadata is valid. Probe takes ~1-2 s and catches the two most common + // silent-hang causes: renderer/GPU config drift and `snapshot.pb` metadata + // corruption. On any hot-boot failure we fall back to cold boot below. + const hasSnapshot = await hasDefaultBootSnapshot(params.avdName); + if (!hasSnapshot) { + hotBootFailureReason = "no default_boot snapshot exists"; + } else { + const probe = await checkSnapshotLoadable(params.avdName, "default_boot"); + if (!probe.loadable) { + hotBootFailureReason = `-check-snapshot-loadable: ${probe.reason ?? "unknown"}`; + } else { + // Hot boot attempt. `-force-snapshot-load` flips the emulator's default + // "silent fallback to cold boot on load failure" into a loud early exit + // so ram.bin corruption (which the probe misses) surfaces in seconds + // rather than hanging for the full overall budget. `-no-snapshot-save` + // avoids overwriting a working snapshot with state captured after we + // later force-kill the child from a failure path. + const hotArgs = ["-avd", params.avdName, "-force-snapshot-load", "-no-snapshot-save"]; + const hotAttemptDeadline = Math.min(overallDeadline, Date.now() + HOT_BOOT_BUDGET_MS); + try { + const result = await attemptBoot({ + avdName: params.avdName, + emulatorBinary, + emulatorArgs: hotArgs, + attemptDeadline: hotAttemptDeadline, + serialsBefore, + // Snapshot restores register with adb within a couple of seconds; + // a minute-long register wait on the hot path would mask the + // scenario where load fails and the child silently cold-boots. + adbRegisterBudgetMs: 30_000, + deviceReadyBudgetMs: 30_000, + bootCompletedBudgetMs: 30_000, + }); + await assertScreencapAlive(result.serial); + return { + platform: "android", + serial: result.serial, + avdName: params.avdName, + booted: true, + }; + } catch (err) { + hotBootFailureReason = err instanceof Error ? err.message : String(err); + // Best-effort: if the hot-boot child registered a serial before + // failing, it's already been killed inside attemptBoot. If it didn't + // register, any detached child was reaped there too. Refresh the + // before-set so the cold-boot attempt doesn't misidentify a zombie + // serial that has not yet disappeared from `adb devices` as "new". + const refreshed = new Set( + (await listAndroidDevices().catch(() => [])).map((d) => d.serial) + ); + for (const s of refreshed) serialsBefore.add(s); + } + } + } + + // Cold boot fallback (either no usable snapshot, or hot-boot attempt failed). + const coldArgs = ["-avd", params.avdName, "-no-snapshot-load"]; + let coldResult: { serial: string }; + try { + coldResult = await attemptBoot({ + avdName: params.avdName, + emulatorBinary, + emulatorArgs: coldArgs, + attemptDeadline: overallDeadline, + serialsBefore, + adbRegisterBudgetMs: STAGE_BUDGET.adbRegister, + deviceReadyBudgetMs: STAGE_BUDGET.deviceReady, + bootCompletedBudgetMs: STAGE_BUDGET.bootCompleted, + }); + } catch (err) { + const base = err instanceof Error ? err.message : String(err); + const suffix = hotBootFailureReason + ? ` Hot-boot was not viable (${hotBootFailureReason}).` + : ""; + throw new Error( + `${base} Emulator has been terminated so the next boot starts clean.` + + ` If this keeps happening, wipe the AVD with \`emulator -avd ${params.avdName} -wipe-data\`.${suffix}` + ); + } + + return { + platform: "android", + serial: coldResult.serial, + avdName: params.avdName, + booted: true, + }; +} + +/** + * Poll an exit-state getter and reject as soon as it returns non-null. + * Used to race against a blocking adb call so a detached-emulator crash + * surfaces as its specific error instead of a generic adb timeout. + * + * Returns `{ promise, cancel }`: the caller must call `cancel()` once the + * race resolves, otherwise the recursive `setTimeout` chain keeps firing + * for the life of the process — a real handle leak across many boot/restart + * cycles. Always invoke `cancel()` in a `finally` block. + */ +function createEarlyExitRacer(getExit: () => Error | null): { + promise: Promise; + cancel: () => void; +} { + let timer: ReturnType | null = null; + let cancelled = false; + const promise = new Promise((_resolve, reject) => { + const tick = () => { + if (cancelled) return; + const err = getExit(); + if (err) { + reject(err); + return; + } + timer = setTimeout(tick, 500); + }; + timer = setTimeout(tick, 500); + }); + return { + promise, + cancel: () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + }, + }; +} + +// boot-device dispatches internally on `udid` vs `avdName` rather than via +// `dispatchByPlatform` (the helper assumes a single udid input). Capability +// is still declared so the HTTP gate rejects an iOS udid on a host without +// xcrun, etc., and so `list-devices` consumers can rely on uniform metadata. +const capability: ToolCapability = { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, +}; + +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 }. +Android boots take 2–10 minutes depending on machine and cold/warm state; the tool transparently hot-boots from the AVD's default_boot snapshot when usable and falls back to cold boot otherwise. If any boot stage fails, the tool terminates the emulator it spawned so the next retry starts clean.`, + alwaysLoad: true, + searchHint: "boot start launch simulator emulator avd device session ios android cold hot", + zodSchema, + capability, + 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!, + 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..98380f49 --- /dev/null +++ b/packages/tool-server/src/tools/devices/list-devices.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import type { ToolDefinition } from "@argent/registry"; +import { listAndroidDevices, listAvds } from "../../utils/adb"; +import { listIosSimulators, type IosSimulator } from "../../utils/ios-devices"; + +type IosDevice = IosSimulator & { platform: "ios" }; + +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 }>; +}; + +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 CLI is unavailable are silently omitted — an empty result usually means xcode-select or Android platform-tools is not installed.`, + alwaysLoad: true, + searchHint: "list devices simulators emulators avd serial udid ios android session start", + zodSchema, + services: () => ({}), + async execute(_services, _params) { + const [ios, android, avds] = await Promise.all([ + listIosSimulators(), + listAndroidDevices().catch(() => []), + listAvds(), + ]); + const iosTagged: IosDevice[] = ios.map((s) => ({ platform: "ios", ...s })); + iosTagged.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: [...iosTagged, ...androidTagged], avds }; + }, +}; diff --git a/packages/tool-server/src/tools/gesture-custom/index.ts b/packages/tool-server/src/tools/gesture-custom/index.ts index 88233add..06d95aa4 100644 --- a/packages/tool-server/src/tools/gesture-custom/index.ts +++ b/packages/tool-server/src/tools/gesture-custom/index.ts @@ -1,8 +1,11 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type GestureCustomResult, type GestureCustomServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +import { sendCommand } from "../../utils/simulator-client"; +import { interpolateEvents } from "../../utils/gesture-utils"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const eventSchema = z.object({ type: z.enum(["Down", "Move", "Up"]).describe("Touch event type"), @@ -23,7 +26,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( @@ -41,20 +44,24 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + events: number; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, - // android: not yet implemented; flip on once `customAndroid` is real. + android: { emulator: true, device: true, unknown: true }, }; -export const gestureCustomTool: ToolDefinition = { +export const gestureCustomTool: ToolDefinition = { id: "gesture-custom", description: `Send a sequence of touch events for complex gestures. 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}] @@ -71,12 +78,26 @@ Example pinch-to-zoom (with interpolate:10 for smoothness): zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "gesture-custom", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + const events = + params.interpolate && params.interpolate > 0 + ? interpolateEvents(params.events, params.interpolate) + : params.events; + + for (const event of events) { + await sleep(event.delayMs ?? 16); + sendCommand(api, { + cmd: "touch", + type: event.type, + x: event.x, + y: event.y, + second_x: event.x2 ?? null, + second_y: event.y2 ?? null, + }); + } + return { events: events.length }; + }, }; diff --git a/packages/tool-server/src/tools/gesture-custom/platforms/android.ts b/packages/tool-server/src/tools/gesture-custom/platforms/android.ts deleted file mode 100644 index a72f28fe..00000000 --- a/packages/tool-server/src/tools/gesture-custom/platforms/android.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { GestureCustomParams, GestureCustomResult, GestureCustomServices } from "./ios"; - -export const androidImpl: PlatformImpl< - GestureCustomServices, - GestureCustomParams, - GestureCustomResult -> = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "gesture-custom", - platform: "android", - hint: - "Single-touch sequences map to `adb shell sendevent` events. Multi-touch (x2, y2) " + - "requires UiAutomator instrumentation — adb sendevent does not expose a second " + - "touch slot. Convert normalized coordinates to device pixels via `adb shell wm size`.", - }); - }, -}; diff --git a/packages/tool-server/src/tools/gesture-custom/platforms/ios.ts b/packages/tool-server/src/tools/gesture-custom/platforms/ios.ts deleted file mode 100644 index fe0b8b75..00000000 --- a/packages/tool-server/src/tools/gesture-custom/platforms/ios.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sendCommand } from "../../../utils/simulator-client"; -import { interpolateEvents } from "../../../utils/gesture-utils"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -export interface GestureCustomEvent { - type: "Down" | "Move" | "Up"; - x: number; - y: number; - x2?: number; - y2?: number; - delayMs?: number; -} - -export interface GestureCustomParams { - udid: string; - events: GestureCustomEvent[]; - interpolate?: number; -} - -export interface GestureCustomResult { - events: number; -} - -export interface GestureCustomServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl< - GestureCustomServices, - GestureCustomParams, - GestureCustomResult -> = { - handler: async (services, params) => { - const api = services.simulatorServer; - const events = - params.interpolate && params.interpolate > 0 - ? interpolateEvents(params.events, params.interpolate) - : params.events; - - for (const event of events) { - await sleep(event.delayMs ?? 16); - sendCommand(api, { - cmd: "touch", - type: event.type, - x: event.x, - y: event.y, - second_x: event.x2 ?? null, - second_y: event.y2 ?? null, - }); - } - return { events: events.length }; - }, -}; diff --git a/packages/tool-server/src/tools/gesture-pinch/index.ts b/packages/tool-server/src/tools/gesture-pinch/index.ts index d76b3492..b1c3a9da 100644 --- a/packages/tool-server/src/tools/gesture-pinch/index.ts +++ b/packages/tool-server/src/tools/gesture-pinch/index.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type GesturePinchResult, type GesturePinchServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +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( @@ -42,28 +42,56 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + pinched: boolean; + timestampMs: number; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, - // android: requires instrumentation-based backend (UiAutomator); stub kept - // as a placeholder so the directory layout matches every other tool. + android: { emulator: true, device: true, unknown: true }, }; -export const gesturePinchTool: ToolDefinition = { +export const gesturePinchTool: ToolDefinition = { 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.`, +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 / emulator backend is not reachable for the given device.`, zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "gesture-pinch", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + const duration = params.durationMs ?? 300; + const steps = Math.max(1, Math.round(duration / 16)); + const angleDeg = params.angle ?? 0; + const angleRad = (angleDeg * Math.PI) / 180; + const cosA = Math.cos(angleRad); + const sinA = Math.sin(angleRad); + + let timestampMs = 0; + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const dist = params.startDistance + (params.endDistance - params.startDistance) * t; + const halfDist = dist / 2; + + const x1 = params.centerX - halfDist * cosA; + const y1 = params.centerY - halfDist * sinA; + const x2 = params.centerX + halfDist * cosA; + const y2 = params.centerY + halfDist * sinA; + + const type = i === 0 ? "Down" : i === steps ? "Up" : "Move"; + if (i === 0) timestampMs = Date.now(); + + sendTouchEvent(api, type, x1, y1, x2, y2); + if (i < steps) await sleep(16); + } + + return { pinched: true, timestampMs }; + }, }; diff --git a/packages/tool-server/src/tools/gesture-pinch/platforms/android.ts b/packages/tool-server/src/tools/gesture-pinch/platforms/android.ts deleted file mode 100644 index 8839134c..00000000 --- a/packages/tool-server/src/tools/gesture-pinch/platforms/android.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { GesturePinchParams, GesturePinchResult, GesturePinchServices } from "./ios"; - -export const androidImpl: PlatformImpl< - GesturePinchServices, - GesturePinchParams, - GesturePinchResult -> = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "gesture-pinch", - platform: "android", - hint: - "Multi-touch — adb sendevent does not expose second-touch coordinates. " + - "Requires UiAutomator instrumentation (UiObject.pinchIn / pinchOut) or a " + - "simulator-server-android backend.", - }); - }, -}; diff --git a/packages/tool-server/src/tools/gesture-pinch/platforms/ios.ts b/packages/tool-server/src/tools/gesture-pinch/platforms/ios.ts deleted file mode 100644 index 6cf52685..00000000 --- a/packages/tool-server/src/tools/gesture-pinch/platforms/ios.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sleep, sendTouchEvent } from "../../../utils/gesture-utils"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -export interface GesturePinchParams { - udid: string; - centerX: number; - centerY: number; - startDistance: number; - endDistance: number; - angle?: number; - durationMs?: number; -} - -export interface GesturePinchResult { - pinched: boolean; - timestampMs: number; -} - -export interface GesturePinchServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl = { - handler: async (services, params) => { - const api = services.simulatorServer; - const duration = params.durationMs ?? 300; - const steps = Math.max(1, Math.round(duration / 16)); - const angleDeg = params.angle ?? 0; - const angleRad = (angleDeg * Math.PI) / 180; - const cosA = Math.cos(angleRad); - const sinA = Math.sin(angleRad); - - let timestampMs = 0; - - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const dist = params.startDistance + (params.endDistance - params.startDistance) * t; - const halfDist = dist / 2; - - const x1 = params.centerX - halfDist * cosA; - const y1 = params.centerY - halfDist * sinA; - const x2 = params.centerX + halfDist * cosA; - const y2 = params.centerY + halfDist * sinA; - - const type = i === 0 ? "Down" : i === steps ? "Up" : "Move"; - if (i === 0) timestampMs = Date.now(); - - sendTouchEvent(api, type, x1, y1, x2, y2); - if (i < steps) await sleep(16); - } - - return { pinched: true, timestampMs }; - }, -}; diff --git a/packages/tool-server/src/tools/gesture-rotate/index.ts b/packages/tool-server/src/tools/gesture-rotate/index.ts index bf3f2106..f3f26175 100644 --- a/packages/tool-server/src/tools/gesture-rotate/index.ts +++ b/packages/tool-server/src/tools/gesture-rotate/index.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type GestureRotateResult, type GestureRotateServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +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( @@ -32,26 +32,52 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + rotated: boolean; + timestampMs: number; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; -export const gestureRotateTool: ToolDefinition = { +export const gestureRotateTool: ToolDefinition = { 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.`, +Use when you need to rotate a map, image picker, or any rotateable UI element. Returns { rotated: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device.`, zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "gesture-rotate", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + const duration = params.durationMs ?? 300; + const steps = Math.max(1, Math.round(duration / 16)); + + let timestampMs = 0; + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const angleDeg = params.startAngle + (params.endAngle - params.startAngle) * t; + const angleRad = (angleDeg * Math.PI) / 180; + + const x1 = params.centerX + params.radius * Math.cos(angleRad); + const y1 = params.centerY + params.radius * Math.sin(angleRad); + const x2 = params.centerX - params.radius * Math.cos(angleRad); + const y2 = params.centerY - params.radius * Math.sin(angleRad); + + const type = i === 0 ? "Down" : i === steps ? "Up" : "Move"; + if (i === 0) timestampMs = Date.now(); + + sendTouchEvent(api, type, x1, y1, x2, y2); + if (i < steps) await sleep(16); + } + + return { rotated: true, timestampMs }; + }, }; diff --git a/packages/tool-server/src/tools/gesture-rotate/platforms/android.ts b/packages/tool-server/src/tools/gesture-rotate/platforms/android.ts deleted file mode 100644 index a020b135..00000000 --- a/packages/tool-server/src/tools/gesture-rotate/platforms/android.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { GestureRotateParams, GestureRotateResult, GestureRotateServices } from "./ios"; - -export const androidImpl: PlatformImpl< - GestureRotateServices, - GestureRotateParams, - GestureRotateResult -> = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "gesture-rotate", - platform: "android", - hint: - "Multi-touch — same constraint as gesture-pinch. Requires UiAutomator " + - "instrumentation; adb sendevent does not support second-touch coordinates.", - }); - }, -}; diff --git a/packages/tool-server/src/tools/gesture-rotate/platforms/ios.ts b/packages/tool-server/src/tools/gesture-rotate/platforms/ios.ts deleted file mode 100644 index 9dc62f01..00000000 --- a/packages/tool-server/src/tools/gesture-rotate/platforms/ios.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sleep, sendTouchEvent } from "../../../utils/gesture-utils"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -export interface GestureRotateParams { - udid: string; - centerX: number; - centerY: number; - radius: number; - startAngle: number; - endAngle: number; - durationMs?: number; -} - -export interface GestureRotateResult { - rotated: boolean; - timestampMs: number; -} - -export interface GestureRotateServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl< - GestureRotateServices, - GestureRotateParams, - GestureRotateResult -> = { - handler: async (services, params) => { - const api = services.simulatorServer; - const duration = params.durationMs ?? 300; - const steps = Math.max(1, Math.round(duration / 16)); - - let timestampMs = 0; - - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const angleDeg = params.startAngle + (params.endAngle - params.startAngle) * t; - const angleRad = (angleDeg * Math.PI) / 180; - - const x1 = params.centerX + params.radius * Math.cos(angleRad); - const y1 = params.centerY + params.radius * Math.sin(angleRad); - const x2 = params.centerX - params.radius * Math.cos(angleRad); - const y2 = params.centerY - params.radius * Math.sin(angleRad); - - const type = i === 0 ? "Down" : i === steps ? "Up" : "Move"; - if (i === 0) timestampMs = Date.now(); - - sendTouchEvent(api, type, x1, y1, x2, y2); - if (i < steps) await sleep(16); - } - - return { rotated: true, timestampMs }; - }, -}; diff --git a/packages/tool-server/src/tools/gesture-swipe/index.ts b/packages/tool-server/src/tools/gesture-swipe/index.ts index 7a136b06..27a80f6f 100644 --- a/packages/tool-server/src/tools/gesture-swipe/index.ts +++ b/packages/tool-server/src/tools/gesture-swipe/index.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type GestureSwipeResult, type GestureSwipeServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +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("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)"), @@ -18,29 +20,53 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + swiped: boolean; + timestampMs: number; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, - // android: not yet implemented; flip on once `swipeAndroid` is real. + android: { emulator: true, device: true, unknown: true }, }; -export const gestureSwipeTool: ToolDefinition = { +export const gestureSwipeTool: ToolDefinition = { 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 the device (iOS simulator or Android emulator). 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 / emulator backend is not reachable for the given device.`, alwaysLoad: true, - searchHint: "swipe scroll drag pan gesture simulator touch move", + searchHint: "swipe scroll drag pan gesture device simulator emulator touch move", zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "gesture-swipe", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + const duration = params.durationMs ?? 300; + const steps = Math.max(1, Math.round(duration / 16)); + let timestampMs = 0; + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = params.fromX + (params.toX - params.fromX) * t; + const y = params.fromY + (params.toY - params.fromY) * t; + const type = i === 0 ? "Down" : i === steps ? "Up" : "Move"; + if (i === 0) timestampMs = Date.now(); + sendCommand(api, { + cmd: "touch", + type, + x, + y, + second_x: null, + second_y: null, + }); + if (i < steps) await sleep(16); + } + + return { swiped: true, timestampMs }; + }, }; diff --git a/packages/tool-server/src/tools/gesture-swipe/platforms/android.ts b/packages/tool-server/src/tools/gesture-swipe/platforms/android.ts deleted file mode 100644 index df07010f..00000000 --- a/packages/tool-server/src/tools/gesture-swipe/platforms/android.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { GestureSwipeParams, GestureSwipeResult, GestureSwipeServices } from "./ios"; - -export const androidImpl: PlatformImpl< - GestureSwipeServices, - GestureSwipeParams, - GestureSwipeResult -> = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "gesture-swipe", - platform: "android", - hint: - "Use `adb shell input swipe ` (coordinates " + - "in device pixels — convert from normalized via `adb shell wm size`).", - }); - }, -}; diff --git a/packages/tool-server/src/tools/gesture-swipe/platforms/ios.ts b/packages/tool-server/src/tools/gesture-swipe/platforms/ios.ts deleted file mode 100644 index c78408c6..00000000 --- a/packages/tool-server/src/tools/gesture-swipe/platforms/ios.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sendCommand } from "../../../utils/simulator-client"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -export interface GestureSwipeParams { - udid: string; - fromX: number; - fromY: number; - toX: number; - toY: number; - durationMs?: number; -} - -export interface GestureSwipeResult { - swiped: boolean; - timestampMs: number; -} - -export interface GestureSwipeServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl = { - handler: async (services, params) => { - const api = services.simulatorServer; - const duration = params.durationMs ?? 300; - const steps = Math.max(1, Math.round(duration / 16)); - let timestampMs = 0; - - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const x = params.fromX + (params.toX - params.fromX) * t; - const y = params.fromY + (params.toY - params.fromY) * t; - const type = i === 0 ? "Down" : i === steps ? "Up" : "Move"; - if (i === 0) timestampMs = Date.now(); - sendCommand(api, { - cmd: "touch", - type, - x, - y, - second_x: null, - second_y: null, - }); - if (i < steps) await sleep(16); - } - - return { swiped: true, timestampMs }; - }, -}; diff --git a/packages/tool-server/src/tools/gesture-tap/index.ts b/packages/tool-server/src/tools/gesture-tap/index.ts index 300b257f..528fc183 100644 --- a/packages/tool-server/src/tools/gesture-tap/index.ts +++ b/packages/tool-server/src/tools/gesture-tap/index.ts @@ -1,40 +1,63 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type GestureTapResult, type GestureTapServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +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("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"), }); type Params = z.infer; +interface Result { + tapped: boolean; + timestampMs: number; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, - // android: not yet implemented; flip on once `androidImpl.handler` is real. + android: { emulator: true, device: true, unknown: true }, }; -export const gestureTapTool: ToolDefinition = { +export const gestureTapTool: ToolDefinition = { 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 device screen (iOS simulator or Android emulator) 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. 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 \`argent-simulator-interact\` skill`, +Use when you need to tap a button, link, or any tappable element on the screen. +Returns { tapped: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device. +Before tapping, determine the correct coordinates by using discovery tools: describe, native-describe-screen, debugger-component-tree. More information in \`argent-device-interact\` skill`, alwaysLoad: true, - searchHint: "tap press button element simulator touch down up", + searchHint: "tap press button element device simulator emulator touch down up", zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "gesture-tap", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + const timestampMs = Date.now(); + sendCommand(api, { + cmd: "touch", + type: "Down", + x: params.x, + y: params.y, + second_x: null, + second_y: null, + }); + await sleep(50); + sendCommand(api, { + cmd: "touch", + type: "Up", + x: params.x, + y: params.y, + second_x: null, + second_y: null, + }); + return { tapped: true, timestampMs }; + }, }; diff --git a/packages/tool-server/src/tools/gesture-tap/platforms/android.ts b/packages/tool-server/src/tools/gesture-tap/platforms/android.ts deleted file mode 100644 index d9f251b0..00000000 --- a/packages/tool-server/src/tools/gesture-tap/platforms/android.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { GestureTapParams, GestureTapResult, GestureTapServices } from "./ios"; - -export const androidImpl: PlatformImpl = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "gesture-tap", - platform: "android", - hint: - "Use `adb shell input tap ` (device pixels — convert from " + - "normalized via `adb shell wm size`).", - }); - }, -}; diff --git a/packages/tool-server/src/tools/gesture-tap/platforms/ios.ts b/packages/tool-server/src/tools/gesture-tap/platforms/ios.ts deleted file mode 100644 index fe83f3b6..00000000 --- a/packages/tool-server/src/tools/gesture-tap/platforms/ios.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sendCommand } from "../../../utils/simulator-client"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -export interface GestureTapParams { - udid: string; - x: number; - y: number; -} - -export interface GestureTapResult { - tapped: boolean; - timestampMs: number; -} - -export interface GestureTapServices { - simulatorServer: SimulatorServerApi; -} - -// iOS gesture-tap goes through the bundled simulator-server binary, not xcrun -// directly — leave `requires` empty here. xcrun is only declared on tools that -// shell out to it themselves (launch-app, restart-app, reinstall-app, open-url). -export const iosImpl: PlatformImpl = { - handler: async (services, params) => { - const api = services.simulatorServer; - const timestampMs = Date.now(); - sendCommand(api, { - cmd: "touch", - type: "Down", - x: params.x, - y: params.y, - second_x: null, - second_y: null, - }); - await sleep(50); - sendCommand(api, { - cmd: "touch", - type: "Up", - x: params.x, - y: params.y, - second_x: null, - second_y: null, - }); - return { tapped: true, timestampMs }; - }, -}; diff --git a/packages/tool-server/src/tools/keyboard/index.ts b/packages/tool-server/src/tools/keyboard/index.ts index 4482da14..ee0309f5 100644 --- a/packages/tool-server/src/tools/keyboard/index.ts +++ b/packages/tool-server/src/tools/keyboard/index.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type KeyboardResult, type KeyboardServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +import { charToKeyPress, NAMED_KEYS, SHIFT_KEYCODE } from "./key-codes"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 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)."), text: z .string() .optional() @@ -23,27 +25,68 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + typed: string; + keys: number; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; -export const keyboardTool: ToolDefinition = { +export const keyboardTool: ToolDefinition = { id: "keyboard", - description: `Type text or press special keys on the simulator using keyboard events. + description: `Type text or press special keys on the device (iOS simulator or Android emulator) using keyboard events. 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 if an unsupported key name is provided or the simulator-server / emulator backend is not reachable for the given device. - 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.`, zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "keyboard", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + const delay = params.delayMs ?? 50; + let keysPressed = 0; + + const pressKeyCode = async (keyCode: number, withShift = false) => { + if (withShift) { + api.pressKey("Down", SHIFT_KEYCODE); + await sleep(10); + } + api.pressKey("Down", keyCode); + await sleep(delay); + api.pressKey("Up", keyCode); + if (withShift) { + await sleep(10); + api.pressKey("Up", SHIFT_KEYCODE); + } + keysPressed++; + }; + + if (params.key) { + const code = NAMED_KEYS[params.key.toLowerCase()]; + if (code == null) { + throw new Error( + `Unknown key "${params.key}". Supported: ${Object.keys(NAMED_KEYS).join(", ")}` + ); + } + await pressKeyCode(code); + } + + if (params.text) { + for (const char of params.text) { + const press = charToKeyPress(char); + if (!press) throw new Error(`No keycode for character "${char}"`); + await pressKeyCode(press.keyCode, press.withShift); + await sleep(delay); + } + } + + return { typed: params.text ?? params.key ?? "", keys: keysPressed }; + }, }; diff --git a/packages/tool-server/src/tools/keyboard/key-codes.ts b/packages/tool-server/src/tools/keyboard/key-codes.ts new file mode 100644 index 00000000..dd6998b9 --- /dev/null +++ b/packages/tool-server/src/tools/keyboard/key-codes.ts @@ -0,0 +1,102 @@ +// USB HID Keyboard Usage Page (0x07) keycodes. +// Reference: https://gist.github.com/MightyPork/6da26e382a7ad91b5496ee55fdc73db2 + +export const SHIFT_KEYCODE = 225; + +const SYMBOL_KEYCODES: Record = { + "\n": 40, + "\r": 40, + "\t": 43, + " ": 44, + "-": 45, + "=": 46, + "[": 47, + "]": 48, + "\\": 49, + ";": 51, + "'": 52, + "`": 53, + ",": 54, + ".": 55, + "/": 56, +}; + +const SHIFTED_SYMBOLS: Record = { + "!": "1", + "@": "2", + "#": "3", + "$": "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + ")": "0", + "_": "-", + "+": "=", + "{": "[", + "}": "]", + "|": "\\", + ":": ";", + '"': "'", + "~": "`", + "<": ",", + ">": ".", + "?": "/", +}; + +export const NAMED_KEYS: Record = { + "enter": 40, + "return": 40, + "escape": 41, + "esc": 41, + "backspace": 42, + "delete": 42, + "tab": 43, + "space": 44, + "arrow-right": 79, + "arrow-left": 80, + "arrow-down": 81, + "arrow-up": 82, + "f1": 58, + "f2": 59, + "f3": 60, + "f4": 61, + "f5": 62, + "f6": 63, + "f7": 64, + "f8": 65, + "f9": 66, + "f10": 67, + "f11": 68, + "f12": 69, +}; + +export interface KeyPress { + keyCode: number; + withShift: boolean; +} + +/** Resolve a single character into the HID keycode + shift modifier required to type it. */ +export function charToKeyPress(char: string): KeyPress | undefined { + if (char.length !== 1) return undefined; + const c = char.charCodeAt(0); + // a–z → 4–29 + if (c >= 0x61 && c <= 0x7a) return { keyCode: c - 0x61 + 4, withShift: false }; + // A–Z → 4–29 with shift + if (c >= 0x41 && c <= 0x5a) return { keyCode: c - 0x41 + 4, withShift: true }; + // 1–9 → 30–38, 0 → 39 + if (c >= 0x31 && c <= 0x39) return { keyCode: c - 0x31 + 30, withShift: false }; + if (char === "0") return { keyCode: 39, withShift: false }; + // Shifted punctuation (!@#$ …) — resolve the unshifted base char, then add shift. + // The base may be a digit (1–9, 0), so recurse rather than looking up SYMBOL_KEYCODES directly. + const base = SHIFTED_SYMBOLS[char]; + if (base !== undefined) { + const basePress = charToKeyPress(base); + if (basePress === undefined) return undefined; + return { keyCode: basePress.keyCode, withShift: true }; + } + const code = SYMBOL_KEYCODES[char]; + if (code === undefined) return undefined; + return { keyCode: code, withShift: false }; +} diff --git a/packages/tool-server/src/tools/keyboard/platforms/android.ts b/packages/tool-server/src/tools/keyboard/platforms/android.ts deleted file mode 100644 index 1dc5f6c1..00000000 --- a/packages/tool-server/src/tools/keyboard/platforms/android.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { KeyboardParams, KeyboardResult, KeyboardServices } from "./ios"; - -export const androidImpl: PlatformImpl = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "keyboard", - platform: "android", - hint: - 'Use `adb shell input text "..."` for character streams and `adb shell input ' + - "keyevent ` for named keys (enter=66, escape=111, backspace=67, " + - "tab=61, arrow-up=19, arrow-down=20, arrow-left=21, arrow-right=22).", - }); - }, -}; diff --git a/packages/tool-server/src/tools/keyboard/platforms/ios.ts b/packages/tool-server/src/tools/keyboard/platforms/ios.ts deleted file mode 100644 index 0e6c3ef3..00000000 --- a/packages/tool-server/src/tools/keyboard/platforms/ios.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -// USB HID Keyboard Usage Page (0x07) keycodes -const CHAR_TO_KEYCODE: Record = { - "a": 4, - "b": 5, - "c": 6, - "d": 7, - "e": 8, - "f": 9, - "g": 10, - "h": 11, - "i": 12, - "j": 13, - "k": 14, - "l": 15, - "m": 16, - "n": 17, - "o": 18, - "p": 19, - "q": 20, - "r": 21, - "s": 22, - "t": 23, - "u": 24, - "v": 25, - "w": 26, - "x": 27, - "y": 28, - "z": 29, - "1": 30, - "2": 31, - "3": 32, - "4": 33, - "5": 34, - "6": 35, - "7": 36, - "8": 37, - "9": 38, - "0": 39, - "\n": 40, - "\r": 40, - "\t": 43, - " ": 44, - "-": 45, - "=": 46, - "[": 47, - "]": 48, - "\\": 49, - ";": 51, - "'": 52, - "`": 53, - ",": 54, - ".": 55, - "/": 56, -}; - -const SHIFT_CHARS: Record = { - "A": "a", - "B": "b", - "C": "c", - "D": "d", - "E": "e", - "F": "f", - "G": "g", - "H": "h", - "I": "i", - "J": "j", - "K": "k", - "L": "l", - "M": "m", - "N": "n", - "O": "o", - "P": "p", - "Q": "q", - "R": "r", - "S": "s", - "T": "t", - "U": "u", - "V": "v", - "W": "w", - "X": "x", - "Y": "y", - "Z": "z", - "!": "1", - "@": "2", - "#": "3", - "$": "4", - "%": "5", - "^": "6", - "&": "7", - "*": "8", - "(": "9", - ")": "0", - "_": "-", - "+": "=", - "{": "[", - "}": "]", - "|": "\\", - ":": ";", - '"': "'", - "~": "`", - "<": ",", - ">": ".", - "?": "/", -}; - -const SHIFT_KEYCODE = 225; - -const NAMED_KEYS: Record = { - "enter": 40, - "return": 40, - "escape": 41, - "esc": 41, - "backspace": 42, - "delete": 42, - "tab": 43, - "space": 44, - "arrow-right": 79, - "arrow-left": 80, - "arrow-down": 81, - "arrow-up": 82, - "f1": 58, - "f2": 59, - "f3": 60, - "f4": 61, - "f5": 62, - "f6": 63, - "f7": 64, - "f8": 65, - "f9": 66, - "f10": 67, - "f11": 68, - "f12": 69, -}; - -export interface KeyboardParams { - udid: string; - text?: string; - key?: string; - delayMs?: number; -} - -export interface KeyboardResult { - typed: string; - keys: number; -} - -export interface KeyboardServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl = { - handler: async (services, params) => { - const api = services.simulatorServer; - const delay = params.delayMs ?? 50; - let keysPressed = 0; - - const pressKeyCode = async (keyCode: number, withShift = false) => { - if (withShift) { - api.pressKey("Down", SHIFT_KEYCODE); - await sleep(10); - } - api.pressKey("Down", keyCode); - await sleep(delay); - api.pressKey("Up", keyCode); - if (withShift) { - await sleep(10); - api.pressKey("Up", SHIFT_KEYCODE); - } - keysPressed++; - }; - - if (params.key) { - const code = NAMED_KEYS[params.key.toLowerCase()]; - if (code == null) { - throw new Error( - `Unknown key "${params.key}". Supported: ${Object.keys(NAMED_KEYS).join(", ")}` - ); - } - await pressKeyCode(code); - } - - if (params.text) { - for (const char of params.text) { - const base = SHIFT_CHARS[char]; - if (base !== undefined) { - const code = CHAR_TO_KEYCODE[base]; - if (code == null) throw new Error(`No keycode for character "${char}"`); - await pressKeyCode(code, true); - } else { - const code = CHAR_TO_KEYCODE[char]; - if (code == null) throw new Error(`No keycode for character "${char}"`); - await pressKeyCode(code); - } - await sleep(delay); - } - } - - return { typed: params.text ?? params.key ?? "", keys: keysPressed }; - }, -}; diff --git a/packages/tool-server/src/tools/launch-app/index.ts b/packages/tool-server/src/tools/launch-app/index.ts index 3eb7cd17..ff6cf4c6 100644 --- a/packages/tool-server/src/tools/launch-app/index.ts +++ b/packages/tool-server/src/tools/launch-app/index.ts @@ -1,46 +1,75 @@ import { z } from "zod"; -import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; +import { nativeDevtoolsRef } from "../../blueprints/native-devtools"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type LaunchAppResult, type LaunchAppServices } from "./platforms/ios"; +import { resolveDevice } from "../../utils/device-info"; +import type { LaunchAppAndroidServices, LaunchAppIosServices, LaunchAppResult } from "./types"; +import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +// 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 +// is letters, digits, underscore, dot, hyphen — but the head must be a letter +// or underscore so a bundleId like `--user` can't masquerade as a flag inside +// `am start -n …` / `cmd package resolve-activity …`. +const BUNDLE_ID_PATTERN = /^[A-Za-z_][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. Leading `-` is also forbidden for flag-injection +// reasons; `.` is allowed as the head so dot-prefixed activities still work. +const ACTIVITY_PATTERN = /^[A-Za-z_.][A-Za-z0-9._/-]*$/; + 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() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), + bundleId: z + .string() + .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() + .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." + ), }); type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; export const launchAppTool: ToolDefinition = { 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 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 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`, +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`, alwaysLoad: true, - searchHint: "open start app bundle id simulator launch", + searchHint: "open start app bundle id package simulator emulator launch", zodSchema, capability, - services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, - }), - execute: dispatchByPlatform({ + // Only iOS needs the native-devtools service for launch-time injection. + // Resolving it on Android would force the iOS-only blueprint to spin up. + services: (params): Record => { + const device = resolveDevice(params.udid); + return device.platform === "ios" ? { nativeDevtools: nativeDevtoolsRef(device) } : {}; + }, + execute: dispatchByPlatform< + LaunchAppIosServices, + LaunchAppAndroidServices, + Params, + LaunchAppResult + >({ toolId: "launch-app", capability, ios: iosImpl, diff --git a/packages/tool-server/src/tools/launch-app/platforms/android.ts b/packages/tool-server/src/tools/launch-app/platforms/android.ts index aceb2e6a..9e888e5b 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/android.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/android.ts @@ -1,21 +1,80 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { LaunchAppParams, LaunchAppResult } from "./ios"; +import { adbShell } from "../../../utils/adb"; +import type { LaunchAppAndroidServices, LaunchAppParams, LaunchAppResult } from "../types"; -// Android launch path doesn't use the iOS native-devtools service; when the -// impl lands, also make `launch-app/index.ts` services() platform-aware so -// the Android branch resolves only the services it actually needs. -export const androidImpl: PlatformImpl = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "launch-app", - platform: "android", - hint: - "Use `adb shell am start -W -n /<.Activity>`. Resolve the launcher " + - "activity via `cmd package resolve-activity --brief ` if not provided. " + - "Also make `launch-app/index.ts` services() platform-aware — Android does " + - "not need the iOS native-devtools service.", - }); - }, -}; +// `am start -W` always prints a `Status:` banner. A positive-match check on +// `Status: ok` is more robust than scanning for keywords like "Error": the old +// /Error|Exception/ matcher false-failed on benign class names such as +// `com.example.ErrorReportingActivity` in the "Activity:" line, and +// false-succeeded on `Status: null` when the activity failed in onCreate. +export function assertAmStartOk(out: string): void { + if (!/Status:\s*ok/i.test(out)) { + throw new Error(`am start failed: ${out.trim()}`); + } + // "Warning: Activity not started, its current task has been brought to the + // front" also comes with Status: ok and means the app is foregrounded. + // That's the behavior callers want from launch-app, so we don't reject it. +} + +// Resolve the package's LAUNCHER activity via `cmd package resolve-activity`. +// Output of `--brief` is one component per line; the last non-empty line is +// `pkg/fully.Qualified.Activity`. This lets the default (no-activity) branch +// use `am start -W` for a proper blocking launch instead of `monkey 1`. +export async function resolveLauncherActivity(udid: string, bundleId: string): Promise { + const raw = await adbShell(udid, `cmd package resolve-activity --brief ${bundleId}`, { + timeoutMs: 10_000, + }); + const last = raw + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .pop(); + if (!last || !/^[\w.]+\/[\w.$]+$/.test(last)) { + throw new Error( + `Could not resolve a LAUNCHER activity for ${bundleId}. ` + + `Install the app first, or pass an explicit \`activity\`. ` + + `(resolve-activity output: ${raw.trim() || "empty"})` + ); + } + return last; +} + +export const androidImpl: PlatformImpl = + { + requires: ["adb"], + handler: async (_services, params) => { + // Resolve a concrete pkg/Activity component for every code path so we + // can always use `am start -W`, which blocks until the activity is + // drawn. The previous `monkey … LAUNCHER 1` fallback returned as soon + // as the intent was injected, leaving a window where describe/tap + // could race a still-forking process. + let component: string; + if (params.activity) { + // Three accepted shapes: + // ".MainActivity" → ${pkg}/.MainActivity (relative) + // "pkg/.X" or "pkg/full.X" → use as-is + // "com.fully.Qualified" → ${pkg}/com.fully.Qualified (FQCN) + // A bare class name like "MainActivity" (no dot, no slash) used to be + // emitted as `${pkg}/MainActivity`, which `am start` rejects because + // an unqualified class is treated as default-package — i.e. no match. + // Resolve the obvious intent by treating it as relative-to-bundleId. + const a = params.activity; + if (a.includes("/")) { + component = a; + } else if (a.startsWith(".")) { + component = `${params.bundleId}/${a}`; + } else if (a.includes(".")) { + component = `${params.bundleId}/${a}`; + } else { + component = `${params.bundleId}/.${a}`; + } + } else { + component = await resolveLauncherActivity(params.udid, params.bundleId); + } + const out = await adbShell(params.udid, `am start -W -n ${component}`, { + timeoutMs: 30_000, + }); + assertAmStartOk(out); + return { launched: true, bundleId: params.bundleId }; + }, + }; diff --git a/packages/tool-server/src/tools/launch-app/platforms/ios.ts b/packages/tool-server/src/tools/launch-app/platforms/ios.ts index 0f8e63a2..7a0019ae 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/ios.ts @@ -1,25 +1,11 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { NativeDevtoolsApi } from "../../../blueprints/native-devtools"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import type { LaunchAppIosServices, LaunchAppParams, LaunchAppResult } from "../types"; const execFileAsync = promisify(execFile); -export interface LaunchAppParams { - udid: string; - bundleId: string; -} - -export interface LaunchAppResult { - launched: boolean; - bundleId: string; -} - -export interface LaunchAppServices { - nativeDevtools: NativeDevtoolsApi; -} - -export const iosImpl: PlatformImpl = { +export const iosImpl: PlatformImpl = { requires: ["xcrun"], handler: async (services, params) => { await services.nativeDevtools.ensureEnvReady(); diff --git a/packages/tool-server/src/tools/launch-app/types.ts b/packages/tool-server/src/tools/launch-app/types.ts new file mode 100644 index 00000000..3b801647 --- /dev/null +++ b/packages/tool-server/src/tools/launch-app/types.ts @@ -0,0 +1,21 @@ +import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; + +export interface LaunchAppParams { + udid: string; + bundleId: string; + /** Android-only: ignored on iOS. */ + activity?: string; +} + +export interface LaunchAppResult { + launched: boolean; + bundleId: string; +} + +// iOS gets the native-devtools service so launch-app can warm DYLD env before +// the app starts. Android's `services()` returns `{}` so its handler typechecks +// against an empty shape — `dispatchByPlatform` keeps the two generics separate. +export interface LaunchAppIosServices { + nativeDevtools: NativeDevtoolsApi; +} +export type LaunchAppAndroidServices = Record; diff --git a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts index 116892b2..8165a148 100644 --- a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts +++ b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { nativeDevtoolsRef, type NativeDevtoolsApi } from "../../blueprints/native-devtools"; +import { resolveDevice } from "../../utils/device-info"; import { parseNativeDescribeScreenResult, type NativeDescribeScreenResult, @@ -34,6 +34,8 @@ type Result = export const nativeDescribeScreenTool: ToolDefinition = { id: "native-describe-screen", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, description: `Read the running app's native accessibility screen description via injected native devtools. Returns a flat list of accessibility leaf elements with: @@ -50,7 +52,7 @@ Useful for evaluating or debugging the lower-level native data that powers the p If status is restart_required: call restart-app then retry.`, zodSchema, services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, + nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { const api = services.nativeDevtools as NativeDevtoolsApi; diff --git a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts index 7c695484..6e12c15c 100644 --- a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts +++ b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { nativeDevtoolsRef, type NativeDevtoolsApi } from "../../blueprints/native-devtools"; +import { resolveDevice } from "../../utils/device-info"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -19,6 +19,8 @@ type Result = { export const nativeDevtoolsStatusTool: ToolDefinition = { id: "native-devtools-status", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, description: `Check whether native devtools are connected to a specific app and whether the next launch is prepared for injection. Use when you need to verify native devtools readiness before calling native-full-hierarchy, native-describe-screen, or native-network-logs. @@ -35,7 +37,7 @@ If requiresRestart is true: call restart-app, then proceed with the native featu Fails if the simulator server is not running for the given UDID or the bundleId is not found.`, zodSchema, services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, + nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { const api = services.nativeDevtools as NativeDevtoolsApi; diff --git a/packages/tool-server/src/tools/native-devtools/native-find-views.ts b/packages/tool-server/src/tools/native-devtools/native-find-views.ts index 3f10bef8..cd86cc5c 100644 --- a/packages/tool-server/src/tools/native-devtools/native-find-views.ts +++ b/packages/tool-server/src/tools/native-devtools/native-find-views.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { nativeDevtoolsRef, type NativeDevtoolsApi } from "../../blueprints/native-devtools"; +import { resolveDevice } from "../../utils/device-info"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -37,6 +37,8 @@ type Result = export const nativeFindViewsTool: ToolDefinition = { id: "native-find-views", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, description: `Search for specific UIViews in the running app by class name, accessibility identifier, label, tag, or React Native nativeID. Use when you need to locate a specific view by its properties without dumping the entire hierarchy. Returns { status: "ok", matches } with matching views including their frames, properties, optional ancestors, and optional children. Much more targeted than native-full-hierarchy. @@ -44,7 +46,7 @@ At least one of className, identifier, label, tag, or nativeID must be provided. Fails if native devtools are not connected, the app is not running, or status is restart_required (call restart-app then retry).`, zodSchema, services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, + nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { const api = services.nativeDevtools as NativeDevtoolsApi; diff --git a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts index c6c368a4..3ad3ac5d 100644 --- a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts +++ b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { nativeDevtoolsRef, type NativeDevtoolsApi } from "../../blueprints/native-devtools"; +import { resolveDevice } from "../../utils/device-info"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -51,6 +51,8 @@ type Result = export const nativeFullHierarchyTool: ToolDefinition = { id: "native-full-hierarchy", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, description: `Get the complete UIKit view tree for the running app. WARNING: Output can be extremely large (100KB–500KB+) for complex apps, especially those built with SwiftUI. Prefer native-find-views for targeted queries. Use skipClasses / skipClassPrefixes to prune SwiftUI internal subtrees and reduce output size. Use the fields param to request only the properties you need. @@ -59,7 +61,7 @@ Returns { status: "ok", windows } with the full view hierarchy, or { status: "re Fails if native devtools are not connected or the app is not running.`, zodSchema, services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, + nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { const api = services.nativeDevtools as NativeDevtoolsApi; diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index c98305b4..a1c532aa 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import type { NativeDevtoolsApi, NetworkEvent } from "../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { + nativeDevtoolsRef, + type NativeDevtoolsApi, + type NetworkEvent, +} from "../../blueprints/native-devtools"; +import { resolveDevice } from "../../utils/device-info"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -21,6 +25,8 @@ type Result = export const nativeNetworkLogsTool: ToolDefinition = { id: "native-network-logs", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that bypass JS fetch. Use when you need to inspect native-level HTTP traffic that is invisible to JS fetch interception. @@ -28,7 +34,7 @@ Returns { status, count, events } where each event contains URL, method, status Fails if native devtools are not connected or the app is not running.`, zodSchema, services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, + nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { const api = services.nativeDevtools as NativeDevtoolsApi; diff --git a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts index dc803dd7..860a6641 100644 --- a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { nativeDevtoolsRef, type NativeDevtoolsApi } from "../../blueprints/native-devtools"; +import { resolveDevice } from "../../utils/device-info"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -56,6 +56,8 @@ type Result = export const nativeUserInteractableViewAtPointTool: ToolDefinition = { id: "native-user-interactable-view-at-point", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, description: `Inspect the deepest UIView at a raw native window point that would actually receive touch input. Unlike native-view-at-point, this respects userInteractionEnabled and is closer to @@ -67,7 +69,7 @@ simulator tap coordinates. If status is restart_required: call restart-app then retry.`, zodSchema, services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, + nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { const api = services.nativeDevtools as NativeDevtoolsApi; diff --git a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts index 02a24203..60d373ee 100644 --- a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { nativeDevtoolsRef, type NativeDevtoolsApi } from "../../blueprints/native-devtools"; +import { resolveDevice } from "../../utils/device-info"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -56,6 +56,8 @@ type Result = export const nativeViewAtPointTool: ToolDefinition = { id: "native-view-at-point", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, description: `Inspect the deepest visible UIView at a raw native window point. Unlike native-user-interactable-view-at-point, this ignores userInteractionEnabled, @@ -67,7 +69,7 @@ simulator tap coordinates. If status is restart_required: call restart-app then retry.`, zodSchema, services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, + nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { const api = services.nativeDevtools as NativeDevtoolsApi; diff --git a/packages/tool-server/src/tools/open-url/index.ts b/packages/tool-server/src/tools/open-url/index.ts index c37e8a56..8023cadd 100644 --- a/packages/tool-server/src/tools/open-url/index.ts +++ b/packages/tool-server/src/tools/open-url/index.ts @@ -1,36 +1,39 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type OpenUrlResult, type OpenUrlServices } from "./platforms/ios"; +import type { OpenUrlResult, OpenUrlServices } from "./types"; +import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; 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() + .min(1) + .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)." + ), }); type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; export const openUrlTool: ToolDefinition = { 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 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, capability, services: () => ({}), - execute: dispatchByPlatform({ + execute: dispatchByPlatform({ toolId: "open-url", capability, ios: iosImpl, diff --git a/packages/tool-server/src/tools/open-url/platforms/android.ts b/packages/tool-server/src/tools/open-url/platforms/android.ts index dff826b1..dfac0755 100644 --- a/packages/tool-server/src/tools/open-url/platforms/android.ts +++ b/packages/tool-server/src/tools/open-url/platforms/android.ts @@ -1,14 +1,26 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { OpenUrlParams, OpenUrlResult, OpenUrlServices } from "./ios"; +import { adbShell } from "../../../utils/adb"; +import type { OpenUrlParams, OpenUrlResult, OpenUrlServices } from "../types"; export const androidImpl: PlatformImpl = { requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "open-url", - platform: "android", - hint: 'Use `adb shell am start -a android.intent.action.VIEW -d ""`.', + handler: async (_services, params) => { + // Single-quote the URL so `adb shell` doesn't reinterpret characters like + // `&`, `?`, `#` or whitespace as shell metachars on the device side. + const quoted = `'${params.url.replace(/'/g, "'\\''")}'`; + const out = await adbShell(params.udid, `am start -a android.intent.action.VIEW -d ${quoted}`, { + timeoutMs: 15_000, }); + // `am start` reports failures via several shapes that don't share an + // `Error:` prefix. Without these, a deep link to a permission-protected + // intent silently returned `{ opened: true }` while nothing happened. + if ( + /Error:|No Activity found|Permission Denial|SecurityException|requires permission|denied/i.test( + out + ) + ) { + throw new Error(`open-url failed: ${out.trim()}`); + } + return { opened: true, url: params.url }; }, }; diff --git a/packages/tool-server/src/tools/open-url/platforms/ios.ts b/packages/tool-server/src/tools/open-url/platforms/ios.ts index 9e9249a3..00fc99ad 100644 --- a/packages/tool-server/src/tools/open-url/platforms/ios.ts +++ b/packages/tool-server/src/tools/open-url/platforms/ios.ts @@ -1,21 +1,10 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import type { OpenUrlParams, OpenUrlResult, OpenUrlServices } from "../types"; const execFileAsync = promisify(execFile); -export interface OpenUrlParams { - udid: string; - url: string; -} - -export interface OpenUrlResult { - opened: boolean; - url: string; -} - -export type OpenUrlServices = Record; - export const iosImpl: PlatformImpl = { requires: ["xcrun"], handler: async (_services, params) => { diff --git a/packages/tool-server/src/tools/open-url/types.ts b/packages/tool-server/src/tools/open-url/types.ts new file mode 100644 index 00000000..2a711202 --- /dev/null +++ b/packages/tool-server/src/tools/open-url/types.ts @@ -0,0 +1,11 @@ +export interface OpenUrlParams { + udid: string; + url: string; +} + +export interface OpenUrlResult { + opened: boolean; + url: string; +} + +export type OpenUrlServices = Record; diff --git a/packages/tool-server/src/tools/paste/index.ts b/packages/tool-server/src/tools/paste/index.ts index 4baea8e9..0ec050cb 100644 --- a/packages/tool-server/src/tools/paste/index.ts +++ b/packages/tool-server/src/tools/paste/index.ts @@ -1,23 +1,30 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type PasteResult, type PasteServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +import { sendCommand } from "../../utils/simulator-client"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z.string().min(1).describe("iOS simulator UDID — paste is iOS-only."), text: z.string().describe("Text to paste into the focused field"), }); type Params = z.infer; +interface Result { + pasted: boolean; +} + +// Capability gate (HTTP layer + dispatchByPlatform-style consumers) rejects +// Android serials with "Tool 'paste' is not supported on android". The handler +// itself is iOS-only — no platforms/ split needed. const capability: ToolCapability = { apple: { simulator: true, device: true }, }; -export const pasteTool: ToolDefinition = { +export const pasteTool: ToolDefinition = { id: "paste", - description: `Fill the focused field on the simulator by pasting text (fastest text entry). + description: `Fill the focused field on the iOS simulator by pasting text (fastest text entry). Use when you need to fill a text input with a long string faster than character-by-character typing. Returns { pasted: true }. Fails if no field is focused or the simulator server is not running. Tap the text field first to focus it, then call paste. @@ -25,12 +32,11 @@ If paste doesn't work for a particular field, use the keyboard tool instead.`, zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "paste", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + sendCommand(api, { cmd: "paste", text: params.text }); + return { pasted: true }; + }, }; diff --git a/packages/tool-server/src/tools/paste/platforms/android.ts b/packages/tool-server/src/tools/paste/platforms/android.ts deleted file mode 100644 index dc3e00a3..00000000 --- a/packages/tool-server/src/tools/paste/platforms/android.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { PasteParams, PasteResult, PasteServices } from "./ios"; - -export const androidImpl: PlatformImpl = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "paste", - platform: "android", - hint: - 'Use `adb shell input text ""` for direct text injection, or write to ' + - "the clipboard via `cmd clipboard` and dispatch KEYCODE_PASTE (279) into the " + - "focused field.", - }); - }, -}; diff --git a/packages/tool-server/src/tools/paste/platforms/ios.ts b/packages/tool-server/src/tools/paste/platforms/ios.ts deleted file mode 100644 index 9cd9f864..00000000 --- a/packages/tool-server/src/tools/paste/platforms/ios.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sendCommand } from "../../../utils/simulator-client"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -export interface PasteParams { - udid: string; - text: string; -} - -export interface PasteResult { - pasted: boolean; -} - -export interface PasteServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl = { - handler: async (services, params) => { - const api = services.simulatorServer; - sendCommand(api, { cmd: "paste", text: params.text }); - return { pasted: true }; - }, -}; diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index 64dfddc1..ab3ff866 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -2,9 +2,10 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import { getCachedProfilerPaths } from "../../../blueprints/react-profiler-session"; import { - IOS_PROFILER_SESSION_NAMESPACE, - type IosProfilerSessionApi, -} from "../../../blueprints/ios-profiler-session"; + nativeProfilerSessionRef, + type NativeProfilerSessionApi, +} from "../../../blueprints/native-profiler-session"; +import { resolveDevice } from "../../../utils/device-info"; import { buildReactAnchor, buildIosAnchor, @@ -36,22 +37,22 @@ interface HangCommitCorrelation { export const profilerCombinedReportTool: ToolDefinition, string> = { id: "profiler-combined-report", - description: `Generate a cross-correlated report combining React Profiler and iOS Instruments data. -Maps iOS Instruments hangs to React commits using wall-clock time alignment. -Requires both react-profiler-analyze and ios-profiler-analyze to have been called first. + description: `Generate a cross-correlated report combining React Profiler and native profiler data. +Maps native hangs to React commits using wall-clock time alignment. +Requires both react-profiler-analyze and native-profiler-analyze to have been called first. Call this tool when both profilers were run in parallel on the same session. Returns a markdown report correlating hangs with React commits, memory leaks, and investigation hints. -Fails if either react-profiler-analyze or ios-profiler-analyze has not been called first.`, +Fails if either react-profiler-analyze or native-profiler-analyze has not been called first.`, zodSchema, services: (params) => ({ - iosSession: `${IOS_PROFILER_SESSION_NAMESPACE}:${params.device_id}`, + nativeSession: nativeProfilerSessionRef(resolveDevice(params.device_id)), }), async execute(services, params) { - const iosApi = services.iosSession as IosProfilerSessionApi; + const nativeApi = services.nativeSession as NativeProfilerSessionApi; // Validate prerequisites - if (!iosApi.parsedData) { - throw new Error("No iOS Instruments data. Run ios-profiler-analyze first."); + if (!nativeApi.parsedData) { + throw new Error("No native profiler data. Run native-profiler-analyze first."); } // Read-only: resolve react paths from cache only — no live CDP connection needed. @@ -72,29 +73,29 @@ Fails if either react-profiler-analyze or ios-profiler-analyze has not been call } const reactWallStart = onDisk.meta?.profileStartWallMs ?? null; - const iosWallStart = iosApi.wallClockStartMs; + const nativeWallStart = nativeApi.wallClockStartMs; - if (!reactWallStart && !iosWallStart) { + if (!reactWallStart && !nativeWallStart) { throw new Error( "Missing wall-clock anchor from both profilers. Re-run the full profiling session " + - "(ios-instruments-start + react-profiler-start)." + "(native-profiler-start + react-profiler-start)." ); } else if (!reactWallStart) { throw new Error( "Missing wall-clock anchor from React Profiler (profileStartWallMs not found). " + "Re-run the profiling session starting with react-profiler-start." ); - } else if (!iosWallStart) { + } else if (!nativeWallStart) { throw new Error( - "Missing wall-clock anchor from iOS Profiler (wallClockStartMs not found). " + - "Re-run the profiling session starting with ios-profiler-start." + "Missing wall-clock anchor from native profiler (wallClockStartMs not found). " + + "Re-run the profiling session starting with native-profiler-start." ); } // Build time anchors const cpuStartUs = cpuProfile?.startTime ?? 0; const reactAnchor = buildReactAnchor(reactWallStart, cpuStartUs); - const iosAnchor = buildIosAnchor(iosWallStart); + const nativeAnchor = buildIosAnchor(nativeWallStart); // Build hot commit summaries from raw data const preprocessed = preprocess(commitTree.commits); @@ -102,7 +103,7 @@ Fails if either react-profiler-analyze or ios-profiler-analyze has not been call const hotCommits = buildHotCommitSummaries(preprocessed, hotIndices); const nonMarginCommits = hotCommits.filter((c) => !c.isMargin); - const { uiHangs, memoryLeaks } = iosApi.parsedData; + const { uiHangs, memoryLeaks } = nativeApi.parsedData; // Tolerance for time alignment: wall clock jitter + the fact that // instruments hang detection and React commit timing may not perfectly align @@ -114,8 +115,8 @@ Fails if either react-profiler-analyze or ios-profiler-analyze has not been call for (const hang of uiHangs) { const hangStartNs = parseHangStartNs(hang.startTimeFormatted); const hangDurationNs = hang.durationMs * 1_000_000; - const hangWallStartMs = instrumentsNsToWallClock(hangStartNs, iosAnchor); - const hangWallEndMs = instrumentsNsToWallClock(hangStartNs + hangDurationNs, iosAnchor); + const hangWallStartMs = instrumentsNsToWallClock(hangStartNs, nativeAnchor); + const hangWallEndMs = instrumentsNsToWallClock(hangStartNs + hangDurationNs, nativeAnchor); const overlapping = nonMarginCommits .map((commit) => { @@ -146,12 +147,12 @@ Fails if either react-profiler-analyze or ios-profiler-analyze has not been call const lines: string[] = [ "# Combined Profiling Report", "", - "React Profiler + iOS Instruments — Cross-Tool Correlation", + "React Profiler + Native Profiler — Cross-Tool Correlation", "", `**React Profiler:** ${nonMarginCommits.length} hot commits `, - `**iOS Instruments:** ${uiHangs.length} hangs, ${memoryLeaks.length} leaks`, + `**Native Profiler:** ${uiHangs.length} hangs, ${memoryLeaks.length} leaks`, "", - `**Clock offset:** React started ${((reactWallStart - iosWallStart) / 1000).toFixed(1)}s ${reactWallStart > iosWallStart ? "after" : "before"} Instruments`, + `**Clock offset:** React started ${((reactWallStart - nativeWallStart) / 1000).toFixed(1)}s ${reactWallStart > nativeWallStart ? "after" : "before"} native profiler`, "", ]; diff --git a/packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-analyze.ts b/packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-analyze.ts deleted file mode 100644 index aff14891..00000000 --- a/packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-analyze.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; -import { - IOS_PROFILER_SESSION_NAMESPACE, - type IosProfilerSessionApi, -} from "../../../blueprints/ios-profiler-session"; -import { runIosProfilerPipeline } from "../../../utils/ios-profiler/pipeline/index"; -import type { IosProfilerAnalyzeResult } from "../../../utils/ios-profiler/types"; -import { renderIosProfilerReport } from "../../../utils/ios-profiler/render"; - -const zodSchema = z.object({ - device_id: z.string().describe("iOS Simulator or device UDID"), -}); - -export const iosInstrumentsAnalyzeTool: ToolDefinition< - z.infer, - IosProfilerAnalyzeResult -> = { - id: "ios-profiler-analyze", - description: `Analyze exported iOS Instruments trace data and return an LLM-optimized markdown report. -Parses CPU time profile, UI hangs, and memory leaks from the exported XML files. -Returns a structured markdown report with severity indicators, tables, and actionable suggestions. -After presenting the report, ask the user whether to investigate further (drill-down with -profiler-stack-query for hang stacks, CPU context, leak details) or implement fixes and re-profile. -Call ios-profiler-stop first to export the trace data. -Use when you need to interpret a completed iOS Instruments recording. -Fails if ios-profiler-stop has not been called first to export trace data.`, - zodSchema, - services: (params) => ({ - session: `${IOS_PROFILER_SESSION_NAMESPACE}:${params.device_id}`, - }), - async execute(services) { - const api = services.session as IosProfilerSessionApi; - - if (!api.exportedFiles) { - throw new Error("No exported trace data found. Call ios-profiler-stop first."); - } - - const { bottlenecks, cpuSamples, uiHangs, cpuHotspots, memoryLeaks } = - await runIosProfilerPipeline(api.exportedFiles); - - api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks }; - - const exportErrors: Record = {}; - if (!api.exportedFiles.cpu) { - exportErrors.cpu = - "CPU time-profile export failed — xctrace could not export CPU data from this trace. " + - "The trace template may not include a Time Profiler instrument, or the schema name " + - "did not match any known CPU profile schema (time-profile, cpu-profile, time-sample). " + - "Check ios-profiler-stop output for exportDiagnostics."; - } - if (!api.exportedFiles.hangs) { - exportErrors.hangs = "Hangs export failed — no potential-hangs table found in trace."; - } - - const payload = { - metadata: { - traceFile: api.traceFile, - platform: "iOS", - timestamp: new Date().toISOString(), - }, - bottlenecks, - }; - - return renderIosProfilerReport({ - payload, - traceFile: api.traceFile, - exportErrors, - }); - }, -}; diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts new file mode 100644 index 00000000..d299a772 --- /dev/null +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts @@ -0,0 +1,115 @@ +import { promises as fs } from "fs"; +import { z } from "zod"; +import type { ToolDefinition } from "@argent/registry"; +import { + nativeProfilerSessionRef, + type NativeProfilerSessionApi, +} from "../../../blueprints/native-profiler-session"; +import { resolveDevice } from "../../../utils/device-info"; +import { runIosProfilerPipeline } from "../../../utils/ios-profiler/pipeline/index"; +import type { IosProfilerAnalyzeResult } from "../../../utils/ios-profiler/types"; +import { renderIosProfilerReport } from "../../../utils/ios-profiler/render"; + +/** + * Distinguish "export skipped" (path null) from "export landed but the file + * is gone/unreadable" (path set, fs.access throws). The XML parsers use + * try/catch returning [] so a missing file silently produces empty data — + * we have to pre-flight here to surface a real warning to the user. + */ +async function checkExportFileMissing(filePath: string | null): Promise { + if (!filePath) return null; + try { + await fs.access(filePath); + return null; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") return `not found at \`${filePath}\``; + if (code === "EACCES") return `unreadable (permission denied) at \`${filePath}\``; + return `unreadable at \`${filePath}\` (${code ?? "unknown error"})`; + } +} + +const zodSchema = z.object({ + device_id: z.string().describe("Target device id from `list-devices`. Currently iOS-only."), +}); + +export const nativeProfilerAnalyzeTool: ToolDefinition< + z.infer, + IosProfilerAnalyzeResult +> = { + id: "native-profiler-analyze", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, + description: `Analyze exported native trace data and return an LLM-optimized markdown report. +iOS: parses CPU time profile, UI hangs, and memory leaks from the exported XML files. +Returns a structured markdown report with severity indicators, tables, and actionable suggestions. +After presenting the report, ask the user whether to investigate further (drill-down with +profiler-stack-query for hang stacks, CPU context, leak details) or implement fixes and re-profile. +Call native-profiler-stop first to export the trace data. +Use when you need to interpret a completed native profiling recording. +Fails if native-profiler-stop has not been called first to export trace data.`, + zodSchema, + services: (params) => ({ + session: nativeProfilerSessionRef(resolveDevice(params.device_id)), + }), + async execute(services) { + const api = services.session as NativeProfilerSessionApi; + + if (!api.exportedFiles) { + throw new Error("No exported trace data found. Call native-profiler-stop first."); + } + + // Pre-flight every set path: if the file is missing/unreadable the parsers + // silently produce [], which would otherwise render as "All clear". + const [cpuMissing, hangsMissing, leaksMissing] = await Promise.all([ + checkExportFileMissing(api.exportedFiles.cpu ?? null), + checkExportFileMissing(api.exportedFiles.hangs ?? null), + checkExportFileMissing(api.exportedFiles.leaks ?? null), + ]); + + const { bottlenecks, cpuSamples, uiHangs, cpuHotspots, memoryLeaks } = + await runIosProfilerPipeline(api.exportedFiles); + + api.parsedData = { cpuSamples, uiHangs, cpuHotspots, memoryLeaks }; + + const exportErrors: Record = {}; + if (!api.exportedFiles.cpu) { + exportErrors.cpu = + "CPU time-profile export failed — xctrace could not export CPU data from this trace. " + + "The trace template may not include a Time Profiler instrument, or the schema name " + + "did not match any known CPU profile schema (time-profile, cpu-profile, time-sample). " + + "Check native-profiler-stop output for exportDiagnostics."; + } else if (cpuMissing) { + exportErrors.cpu = + `CPU time-profile export ${cpuMissing} — the trace export claims it succeeded but the ` + + `file is gone or unreadable, so no CPU data could be analyzed. Re-run native-profiler-stop.`; + } + if (!api.exportedFiles.hangs) { + exportErrors.hangs = "Hangs export failed — no potential-hangs table found in trace."; + } else if (hangsMissing) { + exportErrors.hangs = + `Hangs export ${hangsMissing} — the trace export claims it succeeded but the file is gone ` + + `or unreadable, so no hang data could be analyzed. Re-run native-profiler-stop.`; + } + if (api.exportedFiles.leaks && leaksMissing) { + exportErrors.leaks = + `Leaks export ${leaksMissing} — the trace export claims it succeeded but the file is gone ` + + `or unreadable, so no leak data could be analyzed. Re-run native-profiler-stop.`; + } + + const payload = { + metadata: { + traceFile: api.traceFile, + platform: "iOS", + timestamp: new Date().toISOString(), + }, + bottlenecks, + }; + + return renderIosProfilerReport({ + payload, + traceFile: api.traceFile, + exportErrors, + }); + }, +}; diff --git a/packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-start.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts similarity index 86% rename from packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-start.ts rename to packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts index f84212ef..cbfb4065 100644 --- a/packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-start.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts @@ -3,9 +3,10 @@ import { spawn, execSync, type ChildProcess } from "child_process"; import * as path from "path"; import type { ToolDefinition } from "@argent/registry"; import { - IOS_PROFILER_SESSION_NAMESPACE, - type IosProfilerSessionApi, -} from "../../../blueprints/ios-profiler-session"; + nativeProfilerSessionRef, + type NativeProfilerSessionApi, +} from "../../../blueprints/native-profiler-session"; +import { resolveDevice } from "../../../utils/device-info"; import { getDebugDir } from "../../../utils/react-profiler/debug/dump"; import { listenForDarwinNotification, type NotifyHandle } from "../../../utils/ios-profiler/notify"; import { waitForXctraceReady } from "../../../utils/ios-profiler/startup"; @@ -22,7 +23,7 @@ const RETRY_DELAY_MS = 1_200; const COLD_START_SIGNATURE = "Cannot find process matching name:"; const zodSchema = z.object({ - device_id: z.string().describe("iOS Simulator or device UDID"), + device_id: z.string().describe("Target device id from `list-devices`. Currently iOS-only."), app_process: z .string() .optional() @@ -129,7 +130,7 @@ async function registerStartupNotify(name: string): Promise } catch (err) { const msg = err instanceof Error ? err.message : String(err); process.stderr.write( - `[ios-profiler] failed to spawn notifyutil (${msg}); falling back to stdout substring match.\n` + `[native-profiler] failed to spawn notifyutil (${msg}); falling back to stdout substring match.\n` ); return null; } @@ -142,13 +143,13 @@ async function registerStartupNotify(name: string): Promise handle.cancel(); process.stderr.write( - `[ios-profiler] notifyutil did not register within ${NOTIFY_REGISTER_TIMEOUT_MS} ms; ` + + `[native-profiler] notifyutil did not register within ${NOTIFY_REGISTER_TIMEOUT_MS} ms; ` + `falling back to stdout substring match.\n` ); return null; } -function resetStartState(api: IosProfilerSessionApi): void { +function resetStartState(api: NativeProfilerSessionApi): void { api.xctracePid = null; api.xctraceProcess = null; api.traceFile = null; @@ -156,7 +157,7 @@ function resetStartState(api: IosProfilerSessionApi): void { } export function handleXctraceExit( - api: IosProfilerSessionApi, + api: NativeProfilerSessionApi, code: number | null, signal: string | null ): void { @@ -174,26 +175,28 @@ export function handleXctraceExit( api.lastExitInfo = { code, signal }; } -export const iosInstrumentsStartTool: ToolDefinition< +export const nativeProfilerStartTool: ToolDefinition< z.infer, { status: "recording"; pid: number; traceFile: string } > = { - id: "ios-profiler-start", - description: `Start iOS Instruments profiling via xctrace on a booted simulator or connected device. + id: "native-profiler-start", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, + description: `Start native profiling on a booted device. iOS: Instruments via xctrace (CPU, hangs, memory). Android: not yet supported. Auto-detects the running app process unless app_process is explicitly provided. -After starting, let the user interact with the app, then call ios-profiler-stop. -Use when you want to capture native CPU, hang, and memory data for a running iOS app. +After starting, let the user interact with the app, then call native-profiler-stop. +Use when you want to capture native CPU, hang, and memory data for a running app. Returns { status, pid, traceFile } confirming the recording has started. -Fails if no app is running on the simulator or xctrace cannot attach to the process.`, +Fails if no app is running on the device, the platform is not supported yet, or the profiler cannot attach to the process.`, zodSchema, services: (params) => ({ - session: `${IOS_PROFILER_SESSION_NAMESPACE}:${params.device_id}`, + session: nativeProfilerSessionRef(resolveDevice(params.device_id)), }), async execute(services, params) { - const api = services.session as IosProfilerSessionApi; + const api = services.session as NativeProfilerSessionApi; if (api.profilingActive) { - throw new Error(`An iOS profiling session is already running (PID: ${api.xctracePid}).`); + throw new Error(`A native profiling session is already running (PID: ${api.xctracePid}).`); } const templatePath = params.template_path ?? DEFAULT_TEMPLATE_PATH; @@ -204,7 +207,7 @@ Fails if no app is running on the simulator or xctrace cannot attach to the proc .toISOString() .replace(/[-:T]/g, (m) => (m === "T" ? "-" : "")) .slice(0, 15); - const outputFile = path.join(debugDir, `ios-profiler-${timestamp}.trace`); + const outputFile = path.join(debugDir, `native-profiler-${timestamp}.trace`); api.recordingTimedOut = false; api.recordingExitedUnexpectedly = false; @@ -275,7 +278,7 @@ Fails if no app is running on the simulator or xctrace cannot attach to the proc if (!isColdStart) throw err; if (attempt >= MAX_START_ATTEMPTS) break; process.stderr.write( - `[ios-profiler] xctrace could not find "${appProcess}" on attempt ${attempt}/${MAX_START_ATTEMPTS}; ` + + `[native-profiler] xctrace could not find "${appProcess}" on attempt ${attempt}/${MAX_START_ATTEMPTS}; ` + `waiting ${RETRY_DELAY_MS} ms for cold-start to settle, then retrying.\n` ); await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); diff --git a/packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-stop.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts similarity index 71% rename from packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-stop.ts rename to packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts index d4e2991a..d15d54dc 100644 --- a/packages/tool-server/src/tools/profiler/ios-profiler/ios-profiler-stop.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import { - IOS_PROFILER_SESSION_NAMESPACE, - type IosProfilerSessionApi, -} from "../../../blueprints/ios-profiler-session"; + nativeProfilerSessionRef, + type NativeProfilerSessionApi, +} from "../../../blueprints/native-profiler-session"; +import { resolveDevice } from "../../../utils/device-info"; import { exportIosTraceData } from "../../../utils/ios-profiler/export"; import type { ExportDiagnostics } from "../../../utils/ios-profiler/export"; import { shutdownChild } from "../../../utils/ios-profiler/lifecycle"; @@ -13,7 +14,7 @@ const STOP_TERM_MS = 5_000; const STOP_KILL_MS = 5_000; const zodSchema = z.object({ - device_id: z.string().describe("iOS Simulator or device UDID"), + device_id: z.string().describe("Target device id from `list-devices`. Currently iOS-only."), }); interface StopResult { @@ -23,20 +24,21 @@ interface StopResult { warning?: string; } -export const iosInstrumentsStopTool: ToolDefinition, StopResult> = { - id: "ios-profiler-stop", - description: `Stop iOS Instruments profiling and export trace data to XML files. -Sends SIGINT to the running xctrace process, waits for it to finish packaging the trace, -then exports CPU, hangs, and leaks data. Call ios-profiler-start first. +export const nativeProfilerStopTool: ToolDefinition, StopResult> = { + id: "native-profiler-stop", + requires: ["xcrun"], + capability: { apple: { simulator: true, device: true } }, + description: `Stop native profiling and export trace data to XML files. +iOS: sends SIGINT to xctrace, waits for packaging, then exports CPU, hangs, and leaks data. Call native-profiler-start first. Use when the user has finished the interaction to profile and you need to export the trace. Returns { traceFile, exportedFiles, exportDiagnostics } with paths to the exported XML data. -Fails if no active ios-profiler-start session exists for the given device_id.`, +Fails if no active native-profiler-start session exists for the given device_id.`, zodSchema, services: (params) => ({ - session: `${IOS_PROFILER_SESSION_NAMESPACE}:${params.device_id}`, + session: nativeProfilerSessionRef(resolveDevice(params.device_id)), }), async execute(services) { - const api = services.session as IosProfilerSessionApi; + const api = services.session as NativeProfilerSessionApi; // Recover a recording where xctrace is already gone but the trace bundle // is on disk: either the in-process 10-min cap fired, or xctrace exited @@ -54,18 +56,20 @@ Fails if no active ios-profiler-start session exists for the given device_id.`, const warning = wasTimeout ? "Recording timed out at 10 min cap; exported the partial trace. " + - "Call ios-profiler-start again for a fresh recording." + "Call native-profiler-start again for a fresh recording." : `xctrace exited before stop was called (code=${exitInfo?.code ?? "?"}, ` + `signal=${exitInfo?.signal ?? "?"}); exported the partial trace. ` + "Common causes: attached app terminated, simulator daemon restart. " + - "Call ios-profiler-start again for a fresh recording."; - process.stderr.write(`[ios-profiler] ${warning}\n`); + "Call native-profiler-start again for a fresh recording."; + process.stderr.write(`[native-profiler] ${warning}\n`); return { traceFile, exportedFiles, exportDiagnostics: diagnostics, warning }; } if (!api.profilingActive || !api.xctraceProcess || !api.traceFile) { - throw new Error("No active iOS profiling session found. Call ios-profiler-start first."); + throw new Error( + "No active native profiling session found. Call native-profiler-start first." + ); } if (api.recordingTimeout) { @@ -84,7 +88,7 @@ Fails if no active ios-profiler-start session exists for the given device_id.`, warning = `xctrace did not respond to SIGINT${result.signalUsed === "SIGKILL" ? "/SIGTERM" : ""}; ` + `${result.signalUsed} was used. Trace bundle may be incomplete.`; - process.stderr.write(`[ios-profiler] ${warning}\n`); + process.stderr.write(`[native-profiler] ${warning}\n`); } api.profilingActive = false; diff --git a/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts index 4fc6c0ee..d89dc641 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts @@ -15,7 +15,11 @@ const timeRangeSchema = z.object({ const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), mode: z .enum(["by_component", "by_time_range", "by_index", "cascade_tree"]) .describe( diff --git a/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts index 48f40888..ee1bde9b 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts @@ -22,7 +22,11 @@ const timeWindowSchema = z.object({ const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), mode: z .enum(["top_functions", "time_window", "call_tree", "component_cpu"]) .describe( diff --git a/packages/tool-server/src/tools/profiler/query/profiler-load.ts b/packages/tool-server/src/tools/profiler/query/profiler-load.ts index 61c6d6e6..eae0d812 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -1,33 +1,34 @@ import { z } from "zod"; import { promises as fs } from "fs"; import * as path from "path"; -import type { ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolDefinition } from "@argent/registry"; import { cacheProfilerPaths, type ProfilerSessionPaths, } from "../../../blueprints/react-profiler-session"; import { - IOS_PROFILER_SESSION_NAMESPACE, - type IosProfilerSessionApi, -} from "../../../blueprints/ios-profiler-session"; + nativeProfilerSessionRef, + type NativeProfilerSessionApi, +} from "../../../blueprints/native-profiler-session"; +import { resolveDevice } from "../../../utils/device-info"; import { readCommitTree } from "../../../utils/react-profiler/debug/dump"; import { runIosProfilerPipeline } from "../../../utils/ios-profiler/pipeline/index"; import { getDebugDir } from "../../../utils/react-profiler/debug/dump"; const zodSchema = z.object({ mode: z - .enum(["list", "load_react", "load_instruments"]) + .enum(["list", "load_react", "load_native"]) .describe( "list: show available sessions on disk. " + "load_react: load a React profiler session into memory for query tools. " + - "load_instruments: re-parse iOS Instruments XML files into memory for query tools." + "load_native: re-parse native profiler XML files (xctrace on iOS) into memory for query tools." ), session_id: z .string() .optional() .describe( "Timestamp-based session identifier (e.g. '20250313-143022') from the list output. " + - "Required for load_react and load_instruments modes." + "Required for load_react and load_native modes." ), port: z.coerce .number() @@ -38,7 +39,7 @@ const zodSchema = z.object({ device_id: z .string() .describe( - "iOS Simulator UDID (logicalDeviceId). Used to cache the loaded React session under the correct port+device key, and required to resolve the iOS session for load_instruments." + "Target device id from `list-devices`. Used to cache the loaded React session under the correct port+device key, and required to resolve the native profiler session for load_native." ), }); @@ -51,7 +52,7 @@ async function listSessions(debugDir: string): Promise { } const reactSessions = new Map(); - const instrumentsSessions = new Map(); + const nativeSessions = new Map(); for (const entry of entries) { const reactMatch = entry.match(/^react-profiler-(\d{8}-\d{6})_/); @@ -62,15 +63,15 @@ async function listSessions(debugDir: string): Promise { continue; } - const instrMatch = entry.match(/^ios-profiler-(\d{8}-?\d{6})/); - if (instrMatch) { - const sid = instrMatch[1]; - if (!instrumentsSessions.has(sid)) instrumentsSessions.set(sid, []); - instrumentsSessions.get(sid)!.push(entry); + const nativeMatch = entry.match(/^native-profiler-(\d{8}-?\d{6})/); + if (nativeMatch) { + const sid = nativeMatch[1]; + if (!nativeSessions.has(sid)) nativeSessions.set(sid, []); + nativeSessions.get(sid)!.push(entry); } } - if (reactSessions.size === 0 && instrumentsSessions.size === 0) { + if (reactSessions.size === 0 && nativeSessions.size === 0) { return "_No profiling sessions found in the debug directory._"; } @@ -103,11 +104,11 @@ async function listSessions(debugDir: string): Promise { lines.push(""); } - if (instrumentsSessions.size > 0) { - lines.push("### iOS Instruments Sessions", ""); + if (nativeSessions.size > 0) { + lines.push("### Native Profiler Sessions", ""); lines.push("| Session ID | Files |"); lines.push("|---|---|"); - for (const [sid, files] of [...instrumentsSessions.entries()].sort().reverse()) { + for (const [sid, files] of [...nativeSessions.entries()].sort().reverse()) { const hasCpu = files.some((f) => f.includes("_raw_cpu.xml")); const hasHangs = files.some((f) => f.includes("_raw_hangs.xml")); const hasLeaks = files.some((f) => f.includes("_raw_leaks.xml")); @@ -123,7 +124,7 @@ async function listSessions(debugDir: string): Promise { } lines.push( - "_Use `load_react` or `load_instruments` with the session_id to load data for query tools._" + "_Use `load_react` or `load_native` with the session_id to load data for query tools._" ); return lines.join("\n"); @@ -240,15 +241,15 @@ async function loadReactSession( return lines.join("\n"); } -async function loadInstrumentsSession( +async function loadNativeSession( debugDir: string, sessionId: string, - api: IosProfilerSessionApi + api: NativeProfilerSessionApi ): Promise { // Find exported XML files for this session - const cpuXml = path.join(debugDir, `ios-profiler-${sessionId}_raw_cpu.xml`); - const hangsXml = path.join(debugDir, `ios-profiler-${sessionId}_raw_hangs.xml`); - const leaksXml = path.join(debugDir, `ios-profiler-${sessionId}_raw_leaks.xml`); + const cpuXml = path.join(debugDir, `native-profiler-${sessionId}_raw_cpu.xml`); + const hangsXml = path.join(debugDir, `native-profiler-${sessionId}_raw_hangs.xml`); + const leaksXml = path.join(debugDir, `native-profiler-${sessionId}_raw_leaks.xml`); const files: Record = { cpu: null, @@ -279,8 +280,8 @@ async function loadInstrumentsSession( if (!files.cpu && !files.hangs && !files.leaks) { throw new Error( - `No iOS Instruments XML files found for session "${sessionId}". ` + - `Expected files matching ios-profiler-${sessionId}_raw_*.xml in ${debugDir}` + `No native profiler XML files found for session "${sessionId}". ` + + `Expected files matching native-profiler-${sessionId}_raw_*.xml in ${debugDir}` ); } @@ -290,7 +291,7 @@ async function loadInstrumentsSession( api.exportedFiles = files; const lines: string[] = [ - `Loaded iOS Instruments session \`${sessionId}\`.`, + `Loaded native profiler session \`${sessionId}\`.`, "", `- CPU samples: ${cpuSamples.length}`, `- UI hangs: ${uiHangs.length}`, @@ -306,19 +307,19 @@ async function loadInstrumentsSession( export const profilerLoadTool: ToolDefinition, string> = { id: "profiler-load", description: `Fetch and restore a previously captured profiling session from disk into memory so query tools can operate on it. -This is the disk-restore counterpart to react-profiler-stop/ios-profiler-stop, which write data, and to the query tools (profiler-cpu-query, profiler-commit-query, profiler-stack-query), which read it. +This is the disk-restore counterpart to react-profiler-stop/native-profiler-stop, which write data, and to the query tools (profiler-cpu-query, profiler-commit-query, profiler-stack-query), which read it. Use when you need to revisit past session data without capturing a new recording. Modes: - list: Show all available profiling sessions in the project's debug directory. - load_react: Load a React profiler session (CPU profile + commit tree) into memory. Requires session_id. -- load_instruments: Re-parse iOS Instruments XML files into memory. Requires session_id and device_id. +- load_native: Re-parse native profiler XML files into memory. Requires session_id and device_id. Returns a summary of the loaded session or a session list for the list mode. Fails if the session_id is not found or required XML files are missing from disk.`, zodSchema, services: (params) => { - const svcs: Record = {}; - if (params.mode === "load_instruments") { - svcs.session = `${IOS_PROFILER_SESSION_NAMESPACE}:${params.device_id}`; + const svcs: Record = {}; + if (params.mode === "load_native") { + svcs.session = nativeProfilerSessionRef(resolveDevice(params.device_id)); } return svcs; }, @@ -338,14 +339,14 @@ Fails if the session_id is not found or required XML files are missing from disk return loadReactSession(debugDir, params.session_id, params.port, params.device_id); } - case "load_instruments": { + case "load_native": { if (!params.session_id) { throw new Error( - "load_instruments mode requires the session_id parameter. Use list mode first." + "load_native mode requires the session_id parameter. Use list mode first." ); } - const api = services.session as IosProfilerSessionApi; - return loadInstrumentsSession(debugDir, params.session_id, api); + const api = services.session as NativeProfilerSessionApi; + return loadNativeSession(debugDir, params.session_id, api); } default: diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index 669a9b4b..48113192 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import { - IOS_PROFILER_SESSION_NAMESPACE, - type IosProfilerSessionApi, -} from "../../../blueprints/ios-profiler-session"; + nativeProfilerSessionRef, + type NativeProfilerSessionApi, +} from "../../../blueprints/native-profiler-session"; +import { resolveDevice } from "../../../utils/device-info"; import type { CpuSample, UiHang, CpuHotspot, MemoryLeak } from "../../../utils/ios-profiler/types"; import { findDominantFunction, @@ -34,9 +35,11 @@ const zodSchema = z.object({ .describe("Max results to return (default 15)"), }); -function getParsedData(api: IosProfilerSessionApi) { +function getParsedData(api: NativeProfilerSessionApi) { if (!api.parsedData) { - throw new Error("No parsed trace data. Run ios-profiler-stop → ios-profiler-analyze first."); + throw new Error( + "No parsed trace data. Run native-profiler-stop → native-profiler-analyze first." + ); } return api.parsedData; } @@ -312,22 +315,22 @@ function formatBytes(bytes: number): string { export const profilerStackQueryTool: ToolDefinition, string> = { id: "profiler-stack-query", - description: `Query iOS Instruments trace data for iterative investigation of native performance. -Requires ios-profiler-stop → ios-profiler-analyze to have been called first. + description: `Query native profiler trace data for iterative investigation of native performance. +Requires native-profiler-stop → native-profiler-analyze to have been called first. Modes: - hang_stacks: Full CPU context during a specific hang (by hang_index). - function_callers: Who calls a specific native function and what it calls. - thread_breakdown: CPU time split by thread, optionally filtered. - leak_stacks: Memory leak details, optionally filtered by object_type. -Use when drilling into native hang stacks, thread CPU breakdown, or memory leaks after ios-profiler-analyze. +Use when drilling into native hang stacks, thread CPU breakdown, or memory leaks after native-profiler-analyze. Returns a markdown report with native call stacks, thread weights, or leak details for the selected mode. -Fails if ios-profiler-analyze has not been run or no parsed trace data is in memory.`, +Fails if native-profiler-analyze has not been run or no parsed trace data is in memory.`, zodSchema, services: (params) => ({ - session: `${IOS_PROFILER_SESSION_NAMESPACE}:${params.device_id}`, + session: nativeProfilerSessionRef(resolveDevice(params.device_id)), }), async execute(services, params) { - const api = services.session as IosProfilerSessionApi; + const api = services.session as NativeProfilerSessionApi; const data = getParsedData(api); switch (params.mode) { diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts index 2cf232aa..12c09a20 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts @@ -31,7 +31,11 @@ const annotationSchema = z.object({ const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), project_root: z .string() .describe("Absolute path to the RN project root for session context detection"), diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts index 27a5e263..fd69e261 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts @@ -10,7 +10,11 @@ import { isArgentProfilerFunction } from "../../../utils/react-profiler/pipeline const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), top_n: z.coerce .number() .int() diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts index 8a62e277..af520d74 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts @@ -90,7 +90,11 @@ function buildFiberTreeScript(maxDepth: number, filter: string): string { const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), max_depth: z.coerce .number() .int() diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts index 08f94376..49d99b04 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts @@ -91,7 +91,11 @@ function renderMarkdownTable(entries: RenderEntry[]): string { const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), top_n: z.coerce .number() .int() diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts index ec9f6650..1d90a102 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts @@ -21,7 +21,11 @@ import { const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), sample_interval_us: z.coerce .number() .int() @@ -62,8 +66,8 @@ export function createReactProfilerStartTool( id: "react-profiler-start", description: `Start CPU profiling + React commit capture on the connected Hermes runtime. Delegates React commit capture to the in-app React DevTools backend (ri.startProfiling). -If another tool-server already owns the session, returns { already_running: true, owner, stale, how_to_reclaim } without clobbering their data. Pass { force: true } to reclaim a fresh owner's session, but BEFORE OVERTAKING - ask the user for approval first, see relevant skill for guidance. -Before calling this, ask the user if they also want native iOS profiling (ios-profiler-start) — recommend running both in parallel for a complete picture. +If another tool-server already owns the session, returns { already_running: true, owner, stale, how_to_reclaim } without clobbering their data. Pass { force: true } to reclaim a fresh owner's session, but BEFORE OVERTAKING - ask the user for approval first, see relevant skill for guidance. +Before calling this, ask the user if they also want native profiling (native-profiler-start) — recommend running both in parallel for a complete picture. After starting, ask the user to perform the interaction to profile, then call react-profiler-stop. Returns { started_at, startedAtEpochMs, hermes_version, detected_architecture } on success, or the already_running payload described above. Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot be established.`, diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts index d7e0877e..fe87909e 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts @@ -12,7 +12,11 @@ import type { ProfilerSessionOwner } from "../../../utils/react-profiler/session const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), }); type ReadStateResult = diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts index 0b134ce4..48cae94c 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts @@ -21,7 +21,11 @@ import { const zodSchema = z.object({ port: z.coerce.number().default(8081).describe("Metro server port"), - device_id: z.string().describe("iOS Simulator UDID (logicalDeviceId)."), + device_id: z + .string() + .describe( + "Device logicalDeviceId from debugger-connect (iOS simulator UDID or Android logicalDeviceId)." + ), }); interface StopReadResult { diff --git a/packages/tool-server/src/tools/reinstall-app/index.ts b/packages/tool-server/src/tools/reinstall-app/index.ts index 7fe2dea9..1f4b6b2a 100644 --- a/packages/tool-server/src/tools/reinstall-app/index.ts +++ b/packages/tool-server/src/tools/reinstall-app/index.ts @@ -1,20 +1,24 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type ReinstallAppResult, type ReinstallAppServices } from "./platforms/ios"; +import type { ReinstallAppResult, ReinstallAppServices } from "./types"; +import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; const zodSchema = z.object({ - udid: z.string().describe("Simulator UDID"), + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS UDID or Android serial)."), bundleId: z .string() .describe( - "App bundle identifier to uninstall (e.g. com.example.MyApp). Must match the app at appPath." + "App identifier that matches the bundle at `appPath`. iOS: bundle id (used to uninstall first). Android: package name (used to uninstall first; the install itself identifies the app from the APK)." ), appPath: z .string() .describe( - "Absolute or relative path to the .app bundle to install (e.g. ./build/Build/Products/Debug-iphonesimulator/MyApp.app)" + "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." ), }); @@ -22,16 +26,23 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; export const reinstallAppTool: ToolDefinition = { 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. The previous installation (if any) is uninstalled first so app data and runtime permissions are cleared on both platforms. +Use for a full reinstall after rebuilding, or to start from a clean app state. +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, capability, services: () => ({}), - execute: dispatchByPlatform({ + execute: dispatchByPlatform< + ReinstallAppServices, + ReinstallAppServices, + Params, + ReinstallAppResult + >({ toolId: "reinstall-app", capability, ios: iosImpl, diff --git a/packages/tool-server/src/tools/reinstall-app/platforms/android.ts b/packages/tool-server/src/tools/reinstall-app/platforms/android.ts index 1b702958..8edd3ac4 100644 --- a/packages/tool-server/src/tools/reinstall-app/platforms/android.ts +++ b/packages/tool-server/src/tools/reinstall-app/platforms/android.ts @@ -1,6 +1,7 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; +import { resolve as resolvePath } from "node:path"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { ReinstallAppParams, ReinstallAppResult, ReinstallAppServices } from "./ios"; +import { runAdb } from "../../../utils/adb"; +import type { ReinstallAppParams, ReinstallAppResult, ReinstallAppServices } from "../types"; export const androidImpl: PlatformImpl< ReinstallAppServices, @@ -8,14 +9,28 @@ export const androidImpl: PlatformImpl< ReinstallAppResult > = { requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "reinstall-app", - platform: "android", - hint: - "Use `adb -s install -r [-d allowDowngrade] [-g grantPermissions] " + - "`. The `appPath` param should accept an .apk file when " + - "the device is Android.", - }); + handler: async (_services, params) => { + const { udid, bundleId, appPath } = params; + const absolute = resolvePath(appPath); + + // Match iOS semantics: uninstall first so the reinstall is a clean wipe. + // `pm uninstall` is non-fatal if the package isn't installed (returns + // "Failure [DELETE_FAILED_INTERNAL_ERROR]" or similar); swallow that case. + try { + await runAdb(["-s", udid, "uninstall", bundleId], { timeoutMs: 30_000 }); + } catch { + // App may not be installed — continue to install + } + + // -r - Allow app overwriting (no-op after uninstall, but harmless) + // -d - Allow installations with lower versions + // -g - Prevent permissions popup + const args = ["-s", udid, "install", "-r", "-d", "-g", 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 }; }, }; diff --git a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts index 12719a6f..9cd143ee 100644 --- a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts @@ -1,32 +1,22 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { resolve as resolvePath } from "node:path"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import type { ReinstallAppParams, ReinstallAppResult, ReinstallAppServices } from "../types"; const execFileAsync = promisify(execFile); -export interface ReinstallAppParams { - udid: string; - bundleId: string; - appPath: string; -} - -export interface ReinstallAppResult { - reinstalled: boolean; - bundleId: string; -} - -export type ReinstallAppServices = Record; - export const iosImpl: PlatformImpl = { requires: ["xcrun"], handler: async (_services, params) => { const { udid, bundleId, appPath } = params; + const absolute = resolvePath(appPath); 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/reinstall-app/types.ts b/packages/tool-server/src/tools/reinstall-app/types.ts new file mode 100644 index 00000000..45ca98bb --- /dev/null +++ b/packages/tool-server/src/tools/reinstall-app/types.ts @@ -0,0 +1,12 @@ +export interface ReinstallAppParams { + udid: string; + bundleId: string; + appPath: string; +} + +export interface ReinstallAppResult { + reinstalled: boolean; + bundleId: string; +} + +export type ReinstallAppServices = Record; diff --git a/packages/tool-server/src/tools/restart-app/index.ts b/packages/tool-server/src/tools/restart-app/index.ts index 9488fea5..76767393 100644 --- a/packages/tool-server/src/tools/restart-app/index.ts +++ b/packages/tool-server/src/tools/restart-app/index.ts @@ -1,33 +1,66 @@ import { z } from "zod"; -import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; +import { nativeDevtoolsRef } from "../../blueprints/native-devtools"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type RestartAppResult, type RestartAppServices } from "./platforms/ios"; +import { resolveDevice } from "../../utils/device-info"; +import type { RestartAppAndroidServices, RestartAppIosServices, RestartAppResult } from "./types"; +import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +// Bundle id / package name. Head must be letter or underscore so a bundleId +// like `--user` can't masquerade as a flag inside `am force-stop …`. +const BUNDLE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9._-]*$/; +// Same alphabet as launch-app's ACTIVITY_PATTERN. Leading `.` is allowed so +// shorthand activities like `.MainActivity` work; leading `-` is forbidden +// for flag-injection reasons. +const ACTIVITY_PATTERN = /^[A-Za-z_.][A-Za-z0-9._/-]*$/; + 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() + .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."), + activity: z + .string() + .regex(ACTIVITY_PATTERN, "activity may only contain letters, digits, '.', '_', '-' and '/'") + .optional() + .describe( + "Android-only: relaunch a non-launcher Activity (e.g. `.SettingsActivity` or `com.example/com.example.SettingsActivity`). If omitted, the app's default launcher activity is used. Ignored on iOS." + ), }); type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; export const restartAppTool: ToolDefinition = { 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: `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.`, alwaysLoad: true, - searchHint: "terminate relaunch restart reset app bundle id simulator", + searchHint: "terminate relaunch restart reset app bundle id package simulator emulator", zodSchema, capability, - services: (params) => ({ - nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`, - }), - execute: dispatchByPlatform({ + // Only iOS needs the native-devtools service for relaunch injection. + services: (params): Record => { + const device = resolveDevice(params.udid); + return device.platform === "ios" ? { nativeDevtools: nativeDevtoolsRef(device) } : {}; + }, + execute: dispatchByPlatform< + RestartAppIosServices, + RestartAppAndroidServices, + Params, + RestartAppResult + >({ toolId: "restart-app", capability, ios: iosImpl, diff --git a/packages/tool-server/src/tools/restart-app/platforms/android.ts b/packages/tool-server/src/tools/restart-app/platforms/android.ts index 943c44d4..3a88333e 100644 --- a/packages/tool-server/src/tools/restart-app/platforms/android.ts +++ b/packages/tool-server/src/tools/restart-app/platforms/android.ts @@ -1,17 +1,38 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { RestartAppParams, RestartAppResult } from "./ios"; +import { adbShell } from "../../../utils/adb"; +import { assertAmStartOk, resolveLauncherActivity } from "../../launch-app/platforms/android"; +import type { RestartAppAndroidServices, RestartAppParams, RestartAppResult } from "../types"; -export const androidImpl: PlatformImpl = { +export const androidImpl: PlatformImpl< + RestartAppAndroidServices, + RestartAppParams, + RestartAppResult +> = { requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "restart-app", - platform: "android", - hint: - "Use `adb shell am force-stop ` then `adb shell am start -W -n " + - "/<.Activity>`. Resolve the launcher activity via " + - "`cmd package resolve-activity --brief ` when none is provided.", - }); + handler: async (_services, params) => { + const { udid, bundleId, activity } = params; + await adbShell(udid, `am force-stop ${bundleId}`, { timeoutMs: 15_000 }); + // Match launch-app's relaunch path: `monkey` returns as soon as the intent + // is injected and its /No activities found|Error:/ scrape false-failed on + // legitimate class names like `com.example.ErrorReportingActivity`. Use + // `am start -W -n ` with the same `Status: ok` positive-match + // assertion launch-app moved to. + let component: string; + if (activity) { + component = activity.startsWith(".") + ? `${bundleId}/${activity}` + : activity.includes("/") + ? activity + : `${bundleId}/${activity}`; + } else { + component = await resolveLauncherActivity(udid, bundleId); + } + const out = await adbShell(udid, `am start -W -n ${component}`, { timeoutMs: 30_000 }); + try { + assertAmStartOk(out); + } catch (err) { + throw new Error(`relaunch failed: ${err instanceof Error ? err.message : String(err)}`); + } + return { restarted: true, bundleId }; }, }; diff --git a/packages/tool-server/src/tools/restart-app/platforms/ios.ts b/packages/tool-server/src/tools/restart-app/platforms/ios.ts index 7df4cb75..e525b9b1 100644 --- a/packages/tool-server/src/tools/restart-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/restart-app/platforms/ios.ts @@ -1,25 +1,11 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { NativeDevtoolsApi } from "../../../blueprints/native-devtools"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import type { RestartAppIosServices, RestartAppParams, RestartAppResult } from "../types"; const execFileAsync = promisify(execFile); -export interface RestartAppParams { - udid: string; - bundleId: string; -} - -export interface RestartAppResult { - restarted: boolean; - bundleId: string; -} - -export interface RestartAppServices { - nativeDevtools: NativeDevtoolsApi; -} - -export const iosImpl: PlatformImpl = { +export const iosImpl: PlatformImpl = { requires: ["xcrun"], handler: async (services, params) => { const { udid, bundleId } = params; diff --git a/packages/tool-server/src/tools/restart-app/types.ts b/packages/tool-server/src/tools/restart-app/types.ts new file mode 100644 index 00000000..d238e02e --- /dev/null +++ b/packages/tool-server/src/tools/restart-app/types.ts @@ -0,0 +1,20 @@ +import type { NativeDevtoolsApi } from "../../blueprints/native-devtools"; + +export interface RestartAppParams { + udid: string; + bundleId: string; + activity?: string; +} + +export interface RestartAppResult { + restarted: boolean; + bundleId: string; +} + +// iOS gets the native-devtools service so restart-app can refresh the DYLD env +// before the relaunch. Android's `services()` returns `{}` so its handler types +// against an empty shape — `dispatchByPlatform` keeps the two generics separate. +export interface RestartAppIosServices { + nativeDevtools: NativeDevtoolsApi; +} +export type RestartAppAndroidServices = Record; diff --git a/packages/tool-server/src/tools/rotate/index.ts b/packages/tool-server/src/tools/rotate/index.ts index 24b699ff..263bd3d6 100644 --- a/packages/tool-server/src/tools/rotate/index.ts +++ b/packages/tool-server/src/tools/rotate/index.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type RotateResult, type RotateServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +import { sendCommand } from "../../utils/simulator-client"; 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)."), orientation: z .enum(["Portrait", "LandscapeLeft", "LandscapeRight", "PortraitUpsideDown"]) .describe("Target orientation"), @@ -13,22 +13,28 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + orientation: string; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; -export const rotateTool: ToolDefinition = { +export const rotateTool: ToolDefinition = { 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. +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, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, - }), - execute: dispatchByPlatform({ - toolId: "rotate", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params) { + const api = services.simulatorServer as SimulatorServerApi; + sendCommand(api, { cmd: "rotate", direction: params.orientation }); + return { orientation: params.orientation }; + }, }; diff --git a/packages/tool-server/src/tools/rotate/platforms/android.ts b/packages/tool-server/src/tools/rotate/platforms/android.ts deleted file mode 100644 index 5f72f80f..00000000 --- a/packages/tool-server/src/tools/rotate/platforms/android.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { RotateParams, RotateResult, RotateServices } from "./ios"; - -export const androidImpl: PlatformImpl = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "rotate", - platform: "android", - hint: - "Use `adb shell settings put system user_rotation <0|1|2|3>` " + - "(0=Portrait, 1=LandscapeLeft, 2=PortraitUpsideDown, 3=LandscapeRight). " + - "May also need `accelerometer_rotation=0` to lock orientation.", - }); - }, -}; diff --git a/packages/tool-server/src/tools/rotate/platforms/ios.ts b/packages/tool-server/src/tools/rotate/platforms/ios.ts deleted file mode 100644 index 09f46432..00000000 --- a/packages/tool-server/src/tools/rotate/platforms/ios.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { sendCommand } from "../../../utils/simulator-client"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -export type Orientation = "Portrait" | "LandscapeLeft" | "LandscapeRight" | "PortraitUpsideDown"; - -export interface RotateParams { - udid: string; - orientation: Orientation; -} - -export interface RotateResult { - orientation: string; -} - -export interface RotateServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl = { - handler: async (services, params) => { - const api = services.simulatorServer; - sendCommand(api, { cmd: "rotate", direction: params.orientation }); - return { orientation: params.orientation }; - }, -}; diff --git a/packages/tool-server/src/tools/run-sequence/index.ts b/packages/tool-server/src/tools/run-sequence/index.ts index a2e37462..3d5c4893 100644 --- a/packages/tool-server/src/tools/run-sequence/index.ts +++ b/packages/tool-server/src/tools/run-sequence/index.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { Registry, ToolCapability, ToolDefinition } from "@argent/registry"; +import { simulatorServerRef } from "../../blueprints/simulator-server"; import { resolveDevice } from "../../utils/device-info"; -import { assertSupported } from "../../utils/capability"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -17,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( + "Target device id from `list-devices` (iOS UDID or Android serial) — shared across all steps." + ), steps: z .array( z.object({ @@ -50,11 +54,13 @@ type RunSequenceResult = { }; // run-sequence is platform-neutral by construction: every step is dispatched -// through `registry.invokeTool`, which routes each step's tool by classifyDevice. -// The capability here just gates the *outer* invocation, mirroring the inner tools' -// support matrix so the failure mode is consistent. +// through `registry.invokeTool`, and each step's tool runs its own +// `dispatchByPlatform` against `params.udid`. The capability here just gates +// the *outer* invocation, mirroring the inner tools' support matrix so the +// failure mode is consistent. const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; export function createRunSequenceTool( @@ -62,7 +68,7 @@ export function createRunSequenceTool( ): ToolDefinition { return { id: "run-sequence", - description: `Execute multiple simulator interaction steps in a single call. + description: `Execute multiple device interaction steps in a single call (iOS simulator or Android emulator). 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). @@ -102,12 +108,9 @@ Stops on the first error and returns partial results.`, zodSchema, capability, services: (params) => ({ - simulatorServer: `SimulatorServer:${params.udid}`, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), async execute(_services, params) { - const device = resolveDevice(params.udid); - assertSupported("run-sequence", capability, device); - const { udid, steps } = params; const results: StepResult[] = []; diff --git a/packages/tool-server/src/tools/screenshot/index.ts b/packages/tool-server/src/tools/screenshot/index.ts index 18bf9d42..e29850de 100644 --- a/packages/tool-server/src/tools/screenshot/index.ts +++ b/packages/tool-server/src/tools/screenshot/index.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import type { ToolCapability, ToolDefinition } from "@argent/registry"; -import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { iosImpl, type ScreenshotResult, type ScreenshotServices } from "./platforms/ios"; -import { androidImpl } from "./platforms/android"; +import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; +import { resolveDevice } from "../../utils/device-info"; +import { httpScreenshot } from "../../utils/simulator-client"; 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)."), rotation: z .enum(["Portrait", "LandscapeLeft", "LandscapeRight", "PortraitUpsideDown"]) .optional() @@ -22,29 +22,32 @@ const zodSchema = z.object({ type Params = z.infer; +interface Result { + url: string; + path: string; +} + const capability: ToolCapability = { apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, }; -export const screenshotTool: ToolDefinition = { +export const screenshotTool: ToolDefinition = { id: "screenshot", - description: `Capture a screenshot of the simulator screen. Returns { url, path } and the MCP adapter renders it as a visible image. + description: `Capture a screenshot of the device screen (iOS simulator or Android emulator). 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.`, +Fails if the simulator-server / emulator backend is not reachable for the given device.`, alwaysLoad: true, - searchHint: "simulator screen image capture baseline", + searchHint: "device simulator emulator screen image capture baseline", zodSchema, outputHint: "image", capability, services: (params) => ({ - simulatorServer: { - urn: `SimulatorServer:${params.udid}`, - }, - }), - execute: dispatchByPlatform({ - toolId: "screenshot", - capability, - ios: iosImpl, - android: androidImpl, + simulatorServer: simulatorServerRef(resolveDevice(params.udid)), }), + async execute(services, params, options) { + const api = services.simulatorServer as SimulatorServerApi; + const signal = options?.signal ?? AbortSignal.timeout(16_000); + return httpScreenshot(api, params.rotation, signal, params.scale); + }, }; diff --git a/packages/tool-server/src/tools/screenshot/platforms/android.ts b/packages/tool-server/src/tools/screenshot/platforms/android.ts deleted file mode 100644 index 8e495143..00000000 --- a/packages/tool-server/src/tools/screenshot/platforms/android.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NotImplementedOnPlatformError } from "../../../utils/capability"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import type { ScreenshotParams, ScreenshotResult, ScreenshotServices } from "./ios"; - -export const androidImpl: PlatformImpl = { - requires: ["adb"], - handler: async () => { - throw new NotImplementedOnPlatformError({ - toolId: "screenshot", - platform: "android", - hint: - "Use `adb -s exec-out screencap -p` to get raw PNG bytes; save to " + - "/tmp and return { url, path }. Apply scale/rotation client-side after capture.", - }); - }, -}; diff --git a/packages/tool-server/src/tools/screenshot/platforms/ios.ts b/packages/tool-server/src/tools/screenshot/platforms/ios.ts deleted file mode 100644 index 2426ffde..00000000 --- a/packages/tool-server/src/tools/screenshot/platforms/ios.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { SimulatorServerApi } from "../../../blueprints/simulator-server"; -import { httpScreenshot } from "../../../utils/simulator-client"; -import type { PlatformImpl } from "../../../utils/cross-platform-tool"; - -export interface ScreenshotParams { - udid: string; - rotation?: "Portrait" | "LandscapeLeft" | "LandscapeRight" | "PortraitUpsideDown"; - scale?: number; -} - -export interface ScreenshotResult { - url: string; - path: string; -} - -export interface ScreenshotServices { - simulatorServer: SimulatorServerApi; -} - -export const iosImpl: PlatformImpl = { - handler: async (services, params, _device, options) => { - const api = services.simulatorServer; - const signal = options?.signal ?? AbortSignal.timeout(16_000); - return httpScreenshot(api, params.rotation, signal, params.scale); - }, -}; 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/tools/simulator/stop-all-simulator-servers.ts b/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts index c2c07e33..dec45c27 100644 --- a/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts +++ b/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts @@ -10,7 +10,7 @@ export function createStopAllSimulatorServersTool( ): ToolDefinition { return { id: "stop-all-simulator-servers", - description: `Stop all running simulator-server processes and native devtools services and free their resources. Call this when your session ends or the user says they are done. Returns { stopped } — an array of URNs that were shut down. Fails silently if no servers are running.`, + description: `Stop all running simulator-server processes (iOS + Android) and native devtools services and free their resources. Call this when your session ends or the user says they are done. Returns { stopped } — an array of URNs that were shut down. Fails silently if no servers are running.`, services: () => ({}), async execute() { const snapshot = registry.getSnapshot(); diff --git a/packages/tool-server/src/tools/simulator/stop-simulator-server.ts b/packages/tool-server/src/tools/simulator/stop-simulator-server.ts index 348581be..a9bb5b25 100644 --- a/packages/tool-server/src/tools/simulator/stop-simulator-server.ts +++ b/packages/tool-server/src/tools/simulator/stop-simulator-server.ts @@ -4,7 +4,9 @@ import type { Registry, ToolDefinition } from "@argent/registry"; import { SIMULATOR_SERVER_NAMESPACE } from "../../blueprints/simulator-server"; const zodSchema = z.object({ - udid: z.string().describe("The UDID of the simulator whose server to stop"), + udid: z + .string() + .describe("Target device id (iOS UDID or Android serial) whose simulator-server to stop"), }); export function createStopSimulatorServerTool( @@ -12,7 +14,7 @@ export function createStopSimulatorServerTool( ): ToolDefinition<{ udid: string }, { stopped: boolean; udid: string }> { return { id: "stop-simulator-server", - description: `Stop the simulator-server process for a specific simulator UDID and free its resources. Use when you are done interacting with one simulator but want to keep others running. Returns { stopped, udid }. Fails silently if no server is running for the given UDID.`, + description: `Stop the simulator-server process for a specific device (iOS UDID or Android serial) and free its resources. Use when you are done interacting with one device but want to keep others running. Returns { stopped, udid }. Fails silently if no server is running for the given UDID.`, zodSchema, services: () => ({}), async execute(_services, params) { 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..31ee84e5 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,8 @@ 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/), 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..fc0275bd --- /dev/null +++ b/packages/tool-server/src/utils/adb.ts @@ -0,0 +1,451 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { resolveAndroidBinary } from "./android-binary"; + +const execFileAsync = promisify(execFile); + +// `runAdb` / `runAdbBinary` / `listAvds` / `checkSnapshotLoadable` all used to +// call `execFileAsync("adb"|"emulator", ...)` directly, which only honors PATH. +// Tools that declare `requires: ["adb"]` (or `["emulator"]`) preflight via +// `ensureDep`, which now consults `resolveAndroidBinary` and surfaces a 424 +// with the install hint when neither PATH nor `$ANDROID_HOME` resolves the +// binary — so by the time these helpers run, the resolver returns a real +// path. The thrown messages here are a safety net for direct callers that +// skipped the preflight (unit tests, internal scripts, future code paths) +// rather than the primary user-facing diagnostic. +async function resolveAdbOrThrow(): Promise { + const path = await resolveAndroidBinary("adb"); + if (!path) { + throw new Error( + "`adb` not found on PATH or under `$ANDROID_HOME/platform-tools`. " + + "Install Android SDK Platform Tools or set `$ANDROID_HOME` to your SDK root." + ); + } + return path; +} + +export async function resolveEmulatorOrThrow(): Promise { + const path = await resolveAndroidBinary("emulator"); + if (!path) { + throw new Error( + "`emulator` not found on PATH or under `$ANDROID_HOME/emulator`. " + + "Install the Android Emulator package or set `$ANDROID_HOME` to your SDK root." + ); + } + return path; +} + +export interface AdbRunResult { + stdout: string; + stderr: string; +} + +// On timeout, Node's execFile default kill signal is SIGTERM, which an `adb` +// process blocked on a hung daemon can ignore — leaving the parent waiting +// past the deadline. SIGKILL guarantees the child is reaped at the timeout +// boundary so callers' overall budgets actually hold. +const ADB_KILL_SIGNAL = "SIGKILL" as const; + +function describeAdbFailure(args: string[], err: unknown): Error { + // Prefer adb's own stderr/stdout — that's the actionable diagnostic + // ("device offline", etc.). When both are empty (timeout-SIGKILL, daemon + // hang) fall back to the bare message + signal/killed/code so the failure + // mode is still identifiable instead of a tautological "Command failed". + const e = err as { + code?: string | number | null; + signal?: string | null; + killed?: boolean; + stderr?: string; + stdout?: string; + message?: string; + }; + const argv = args.join(" "); + const ioDetail = (e.stderr ?? "").trim() || (e.stdout ?? "").trim(); + if (ioDetail) return new Error(`adb ${argv} failed: ${ioDetail}`); + const meta: string[] = []; + if (e.killed) meta.push("killed=true"); + if (e.signal) meta.push(`signal=${e.signal}`); + if (e.code) meta.push(`code=${e.code}`); + const baseMsg = (e.message ?? String(err)).trim(); + const suffix = meta.length ? ` (${meta.join(" ")})` : ""; + return new Error(`adb ${argv} failed: ${baseMsg}${suffix}`); +} + +/** + * 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. + * + * On non-zero exit or timeout, throws an Error whose message includes the + * actual `adb` stderr (or stdout) instead of the bare "Command failed". + */ +export async function runAdb( + args: string[], + options: { timeoutMs?: number } = {} +): Promise { + const adbPath = await resolveAdbOrThrow(); + try { + const { stdout, stderr } = await execFileAsync(adbPath, args, { + timeout: options.timeoutMs ?? 30_000, + killSignal: ADB_KILL_SIGNAL, + maxBuffer: 64 * 1024 * 1024, + encoding: "utf-8", + }); + return { stdout, stderr }; + } catch (err) { + throw describeAdbFailure(args, err); + } +} + +/** + * 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. + */ +async function runAdbBinary(args: string[], options: { timeoutMs?: number } = {}): Promise { + const adbPath = await resolveAdbOrThrow(); + try { + const { stdout } = await execFileAsync(adbPath, args, { + timeout: options.timeoutMs ?? 30_000, + killSignal: ADB_KILL_SIGNAL, + maxBuffer: 64 * 1024 * 1024, + encoding: "buffer", + }); + return stdout as unknown as Buffer; + } catch (err) { + throw describeAdbFailure(args, err); + } +} + +/** `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; +} + +// Set of states `adb devices` actually emits — filtering to this set rejects +// daemon-startup banner lines like `* daemon not running; starting now …` / +// `* daemon started successfully *`, which the loose `\S+ \s+ \S+` regex +// otherwise parses as `serial="*", state="daemon"` (or similar) and feeds +// into downstream loops as a phantom device. +const ADB_DEVICE_STATES = new Set([ + "device", + "offline", + "unauthorized", + "authorizing", + "connecting", + "no", + "recovery", + "sideload", + "bootloader", + "host", + "rescue", +]); + +/** + * Parse the tab-separated output of `adb devices` (or `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. Daemon-startup banner lines (the `* daemon …` ones + * adb prints to the same stream when it had to spawn its background server) + * are skipped. + */ +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") || line.startsWith("*")) continue; + // Format: "\t" optionally followed by key:value pairs + const match = line.match(/^(\S+)\s+(\S+)/); + if (!match) continue; + const state = match[2]!; + if (!ADB_DEVICE_STATES.has(state)) continue; + devices.push({ serial: match[1]!, state }); + } + return devices; +} + +/** + * Light-weight listing for callers that only need which serials exist. + * Skips the per-device getprop round-trips so the call is one `adb devices` + * shell-out, not 1 + 3N. Used by `listAndroidDevices` as the first hop before + * it enriches each entry. + */ +async function listAndroidSerials(): Promise> { + const { stdout } = await runAdb(["devices"]); + return parseAdbDevices(stdout); +} + +// Short timeout for enrichment getprops. The default (30 s) is fine for an +// interactive call against a healthy device, but `listAndroidDevices` is on +// the hot path of the boot loop — a single mid-attach device can stall the +// stage budget for 30 s × 3 getprops = the entire adb-register window. 5 s +// is plenty for a getprop on any responsive device. +const ENRICH_TIMEOUT_MS = 5_000; + +/** + * Resolve the AVD name of a running emulator. The property moved from + * `ro.kernel.qemu.avd_name` to `ro.boot.qemu.avd_name` in emulator release 30 + * (Android 11+); we probe the newer one first and fall back to the legacy + * name so both old and new images work. + */ +async function readAvdName(serial: string): Promise { + const modern = await adbShell(serial, "getprop ro.boot.qemu.avd_name", { + timeoutMs: ENRICH_TIMEOUT_MS, + }).catch(() => ""); + if (modern.trim()) return modern.trim(); + const legacy = await adbShell(serial, "getprop ro.kernel.qemu.avd_name", { + timeoutMs: ENRICH_TIMEOUT_MS, + }).catch(() => ""); + return legacy.trim() || null; +} + +/** + * List all Android devices + emulators known to adb, enriched with model, + * AVD name, and SDK level via `getprop`. Use `listAndroidSerials` when you + * only need the state-scoped serial list — it avoids the extra round-trips. + */ +export async function listAndroidDevices(): Promise { + const basic = await listAndroidSerials(); + + 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", { timeoutMs: ENRICH_TIMEOUT_MS }).catch( + () => "" + ), + adbShell(d.serial, "getprop ro.build.version.sdk", { timeoutMs: ENRICH_TIMEOUT_MS }).catch( + () => "" + ), + readAvdName(d.serial), + ]); + const sdkLevel = parseInt(sdk.trim(), 10); + return { + serial: d.serial, + state: d.state, + isEmulator: d.serial.startsWith("emulator-"), + model: model.trim() || null, + avdName: avd, + sdkLevel: Number.isFinite(sdkLevel) ? sdkLevel : null, + }; + }) + ); + return enriched; +} + +// Errors from `adb shell` that mean the device is in a state no boot wait can +// fix. Returning generically and timing out wastes the full budget and hides +// the actionable cause. These patterns match adb stderr (now surfaced through +// runAdb's rewrapped errors) for the named conditions. +// +// adb's real format includes the offending serial in single quotes between +// `device` and the verdict, e.g. `error: device 'emulator-5554' not found` or +// `error: device 'emulator-5554' offline`. The optional `(?: '[^']*')?` group +// tolerates that quoted serial without requiring it, so both adb's real output +// and serial-less paraphrases match. +const TERMINAL_ADB_ERROR_PATTERNS: RegExp[] = [ + /device(?: '[^']*')? unauthorized/i, + /device(?: '[^']*')? not found/i, + /no devices\/emulators found/i, + /device(?: '[^']*')? offline/i, +]; + +function isTerminalAdbError(message: string): boolean { + return TERMINAL_ADB_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + +/** + * 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. + * + * Mid-boot getprop failures (the device is still coming up, the shell isn't + * ready, the daemon is reconnecting) are swallowed and retried. Terminal + * errors (device unauthorized, offline, not found) are NOT — they mean the + * caller needs to take action, and waiting another 2 minutes only hides + * what's wrong. + */ +export async function waitForBootCompleted( + serial: string, + timeoutMs = 120_000, + options: { shouldAbort?: () => Error | null } = {} +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + // Surface emulator-crash errors immediately rather than blocking for the + // full boot budget after the underlying process is already dead. + const abortError = options.shouldAbort?.(); + if (abortError) throw abortError; + try { + const out = await adbShell(serial, "getprop sys.boot_completed", { timeoutMs: 3_000 }); + if (out.trim() === "1") return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (isTerminalAdbError(message)) { + throw new Error( + `Cannot wait for ${serial} to boot — adb reports the device is in a terminal state: ${message}.` + + ` Authorise the device, reconnect it, or pick a different target.` + ); + } + // Otherwise: 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; +} + +// AVD names created by `avdmanager create avd` / Android Studio are limited +// to letters, digits, `.`, `_`, and `-` (no whitespace, no path separators). +// The emulator binary also prints diagnostics like `INFO | ...` and +// `HAX is working and emulator runs in fast virt mode.` on the same stream; +// matching valid-AVD-shape accepts real names while rejecting those lines +// even if they happen to start with INFO or HAX. +const AVD_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; + +/** + * List available AVDs via `emulator -list-avds`. Returns [] if the emulator + * binary is unavailable on the host. Callers that need to distinguish "no + * emulator binary" from "emulator binary present but zero AVDs" should + * preflight via `ensureDep("emulator")` first — that surfaces a 424 with the + * install hint when the resolver can't find the binary, while a genuinely + * empty AVD list still returns `[]`. + */ +export async function listAvds(): Promise { + const emulatorPath = await resolveAndroidBinary("emulator"); + if (!emulatorPath) return []; + try { + const { stdout } = await execFileAsync(emulatorPath, ["-list-avds"], { timeout: 5_000 }); + return stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && AVD_NAME_PATTERN.test(l)) + .map((name) => ({ name })); + } catch { + return []; + } +} + +/** + * Result of probing a named snapshot with `emulator -check-snapshot-loadable`. + * + * `loadable === true` is necessary but NOT sufficient for a successful hot + * boot: the probe validates metadata (snapshot.pb, compatible.pb, hardware.ini) + * and renderer compatibility, but not the integrity of ram.bin. A `ram.bin` + * corrupted by a partial save or a host OOM still returns `Loadable` here and + * later crashes the QEMU child with `std::bad_alloc`. Pair this probe with + * `-force-snapshot-load` in the boot spawn and a tight deadline to catch the + * residual failure cases loudly instead of letting them silently fall back to + * a full cold boot. + */ +export interface SnapshotProbeResult { + loadable: boolean; + reason: string | null; +} + +export async function checkSnapshotLoadable( + avdName: string, + snapshotName = "default_boot", + options: { timeoutMs?: number } = {} +): Promise { + try { + const emulatorPath = await resolveEmulatorOrThrow(); + const { stdout } = await execFileAsync( + emulatorPath, + ["-avd", avdName, "-check-snapshot-loadable", snapshotName], + { timeout: options.timeoutMs ?? 10_000, maxBuffer: 4 * 1024 * 1024 } + ); + const tail = stdout.split("\n").slice(-6).join("\n"); + // The emulator emits an informational "WARNING | change of renderer + // detected." whenever `hardware.ini` and `emu-launch-params.txt` disagree + // (e.g. `hw.gpu.mode=auto` in config.ini vs the resolved `swangle_indirect` + // recorded on save). It is noise, not a failure signal — actual + // incompatibility surfaces as a populated `Reason:` with no `Loadable` + // line (e.g. "snapshot was created with gfxstream=1, but this emulator has + // gfxstream=0"). The final `Loadable` line is the emulator's authoritative + // verdict; trust it. + if (/(^|\n)\s*Loadable\s*(\n|$)/.test(tail)) return { loadable: true, reason: null }; + const reasonMatch = tail.match(/Reason:\s*(.+)/); + return { loadable: false, reason: reasonMatch?.[1]?.trim() ?? "unknown" }; + } catch (err) { + return { + loadable: false, + reason: err instanceof Error ? err.message.slice(0, 200) : "probe failed", + }; + } +} + +/** + * True iff a `default_boot` snapshot directory exists on disk for this AVD. + * Cheap filesystem check — the emulator's own `-snapshot-list` requires + * spawning the emulator once, which is precisely the hang we are trying to + * avoid up-front. + */ +export async function hasDefaultBootSnapshot(avdName: string): Promise { + const { stat } = await import("node:fs/promises"); + const home = process.env.HOME ?? ""; + const candidates = [ + `${home}/.android/avd/${avdName}.avd/snapshots/default_boot`, + `${process.env.ANDROID_AVD_HOME ?? ""}/${avdName}.avd/snapshots/default_boot`, + ].filter((p) => p && p.startsWith("/")); + for (const path of candidates) { + try { + // `snapshot.pb` alone is not enough to call the snapshot "present": + // an OOM-killed emulator can leave the tiny metadata file on disk while + // `ram.bin` is missing, zero-length, or truncated. The -check-snapshot- + // loadable probe reads only metadata and happily reports "Loadable" in + // that state, so the hot-boot attempt spawns, then hangs or crashes on + // a bad RAM restore. Verifying ram.bin is present and non-empty, and + // that its mtime is within a minute of snapshot.pb's (a save writes + // both in one batch), cheaply filters the partial-save class before we + // ever spawn the emulator. + const [metaStat, ramStat] = await Promise.all([ + stat(`${path}/snapshot.pb`), + stat(`${path}/ram.bin`), + ]); + if (ramStat.size === 0) continue; + const mtimeSkewMs = Math.abs(metaStat.mtimeMs - ramStat.mtimeMs); + if (mtimeSkewMs > 60_000) continue; + return true; + } catch { + // keep looking + } + } + return false; +} diff --git a/packages/tool-server/src/utils/android-binary.ts b/packages/tool-server/src/utils/android-binary.ts new file mode 100644 index 00000000..8e4073fa --- /dev/null +++ b/packages/tool-server/src/utils/android-binary.ts @@ -0,0 +1,108 @@ +import { execFile } from "node:child_process"; +import { access } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type AndroidBinaryName = "adb" | "emulator"; + +// Subdirectory under $ANDROID_HOME where each binary ships. `adb` lives in +// `platform-tools/` (separate SDK package); `emulator` lives in `emulator/`. +// Both are the canonical install layouts every Android tool (Studio, +// avdmanager, react-native CLI) assumes — mirroring them is what makes +// argent's resolution behave the same way the rest of the toolchain does. +const SUBDIR: Record = { + adb: "platform-tools", + emulator: "emulator", +}; + +interface CacheEntry { + path: string | null; + checkedAt: number; +} + +// Short TTL for the negative case so a user who installs the missing package +// mid-session recovers without restarting the tool-server. Positive results +// effectively never expire in practice — SDK location doesn't move during a +// session — but the same TTL keeps the eviction logic uniform. +const CACHE_TTL_MS = 60_000; +const cache = new Map(); + +/** + * Resolve an Android SDK binary to an absolute path. + * + * Lookup order — matches what Android Studio, react-native CLI, and the + * `avdmanager`/`sdkmanager` wrappers do, in this priority: + * 1. `command -v ` (PATH) + * 2. `$ANDROID_HOME//` if executable + * 3. `$ANDROID_SDK_ROOT//` if executable + * + * Returns `null` if none of those resolve. Callers that surface the failure + * to users should funnel through `ensureDep` so the missing-binary message + * names the install hint instead of producing a downstream "no AVDs"-style + * symptom. + * + * Argent previously called `execFile("emulator", ...)` and `execFile("adb", + * ...)` directly, which only honors PATH. Users with a working SDK install + * but `$ANDROID_HOME/emulator` not on PATH (the default state on macOS after + * an Android Studio install — Studio sets ANDROID_HOME but leaves PATH + * alone) saw `listAvds()` silently return `[]`, which `boot-device` then + * mis-reported as "no AVDs". This resolver closes that gap. + */ +export async function resolveAndroidBinary(name: AndroidBinaryName): Promise { + const now = Date.now(); + const cached = cache.get(name); + if (cached && now - cached.checkedAt < CACHE_TTL_MS) return cached.path; + const resolved = await probe(name); + cache.set(name, { path: resolved, checkedAt: now }); + return resolved; +} + +async function probe(name: AndroidBinaryName): Promise { + // PATH first — preserves prior behavior for users who already have the + // binary on PATH (e.g. Homebrew adb at /opt/homebrew/bin/adb), and means + // a sysadmin override on PATH still wins over $ANDROID_HOME. + try { + const { stdout } = await execFileAsync("/bin/sh", ["-c", `command -v ${name}`], { + timeout: 2_000, + }); + const trimmed = stdout.trim(); + // `command -v` prints nothing on miss but returns non-zero, so we only + // get here on success — but defend against an empty stdout anyway in + // case a future shell quirk decouples the two. + if (trimmed) return trimmed; + } catch { + // fall through to SDK-root fallbacks + } + for (const root of androidRoots()) { + const candidate = join(root, SUBDIR[name], name); + try { + // X_OK rather than F_OK: a non-executable file at the canonical path + // means a corrupted/partial install, and falling back to the next root + // (or returning null) is the right move — spawning a non-executable + // path would only produce an EACCES at run-time. + await access(candidate, fsConstants.X_OK); + return candidate; + } catch { + // try the next root + } + } + return null; +} + +function androidRoots(): string[] { + // ANDROID_HOME is the canonical env var; ANDROID_SDK_ROOT is the legacy + // alias Android still honors. Some environments set only one. We try both + // in declared order so a user who set ANDROID_HOME explicitly always wins + // over a stale ANDROID_SDK_ROOT inherited from elsewhere. + return [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT].filter((v): v is string => + Boolean(v && v.trim()) + ); +} + +/** Test-only: clear the resolver cache between tests. */ +export function __resetAndroidBinaryCacheForTesting(): void { + cache.clear(); +} 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..09257677 --- /dev/null +++ b/packages/tool-server/src/utils/android-screen.ts @@ -0,0 +1,36 @@ +import { adbShell } from "./adb"; + +export interface AndroidScreenSize { + width: number; + height: number; +} + +/** + * Read the device's current logical screen size via `wm size`. 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). + * + * NOT cached: a 5 s TTL would have served stale dimensions for several + * describes after a rotation (rotation completes in <500 ms), producing + * normalized frames with x>1 / width>1 because the screenW used for the + * divisor was pre-rotation. One extra `adb shell` per `describe` is cheap + * compared to the uiautomator dump exec-out it sits next to. + */ +export async function getAndroidScreenSize(serial: string): Promise { + 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 width = parseInt(match[1]!, 10); + const height = parseInt(match[2]!, 10); + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { + throw new Error(`Got non-positive screen size from \`wm size\`: ${out.trim()}`); + } + return { width, height }; +} diff --git a/packages/tool-server/src/utils/capability.ts b/packages/tool-server/src/utils/capability.ts index c720bd4e..db29092a 100644 --- a/packages/tool-server/src/utils/capability.ts +++ b/packages/tool-server/src/utils/capability.ts @@ -32,12 +32,12 @@ export class UnsupportedOperationError extends Error { * The HTTP dispatcher maps this to `501 Not Implemented` and surfaces the * `hint` field so the agent (and contributor) can see exactly what to wire. * - * Usage in a stub: + * Usage in a stub at `tools//platforms/.ts`: * * throw new NotImplementedOnPlatformError({ - * toolId: "button", + * toolId: "", * platform: "android", - * hint: "Use `adb shell input keyevent ` (home=3, back=4, ...).", + * hint: "Optional one-line nudge — e.g. which adb / xcrun command to wrap.", * }); */ export class NotImplementedOnPlatformError extends Error { diff --git a/packages/tool-server/src/utils/check-deps.ts b/packages/tool-server/src/utils/check-deps.ts index a7987ce4..53e729d0 100644 --- a/packages/tool-server/src/utils/check-deps.ts +++ b/packages/tool-server/src/utils/check-deps.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { ToolDependency } from "@argent/registry"; +import { resolveAndroidBinary } from "./android-binary"; const execFileAsync = promisify(execFile); @@ -19,18 +20,40 @@ export class DependencyMissingError extends Error { } } +// Cache for CACHE_TTL_MS so a burst of tool calls pays at most one `command -v` +// per dep, but an install mid-session (e.g. the user runs `xcode-select +// --install` after a missing-dep error) recovers on its own within a minute +// without needing a tool-server restart. const CACHE_TTL_MS = 60_000; type CacheEntry = { available: boolean; checkedAt: number }; const cache = new Map(); +// Short per-dep hints — the message is what the LLM sees on a missing-dep +// error, so it should tell it how to unblock the user. const INSTALL_HINTS: Record = { xcrun: "Xcode command-line tools are not installed. Run `xcode-select --install` (or install Xcode from the App Store) and retry. Only required for iOS simulators.", - adb: "Android SDK Platform Tools are not installed (`adb` not on PATH). Install with `brew install --cask android-platform-tools` or via Android Studio → SDK Manager, then retry. Only required for Android devices and emulators.", + adb: "Android SDK Platform Tools not found. Install with `brew install --cask android-platform-tools` or via Android Studio → SDK Manager. If installed, ensure `adb` is on PATH or set `$ANDROID_HOME` to the SDK root (the resolver checks `$ANDROID_HOME/platform-tools/adb`). Only required for Android devices and emulators.", + emulator: + "Android Emulator not found. Install via Android Studio → SDK Manager → Emulator, or `sdkmanager 'emulator'`. If installed, ensure `emulator` is on PATH or set `$ANDROID_HOME` to the SDK root (the resolver checks `$ANDROID_HOME/emulator/emulator`). Only required to launch new Android emulators via `boot-device`.", }; async function probe(dep: ToolDependency): Promise { + // Android binaries support an `$ANDROID_HOME` fallback in addition to PATH + // (Android Studio sets ANDROID_HOME but does NOT add `$ANDROID_HOME/emulator` + // to PATH on macOS — the most common state for users coming from Studio). + // Funnel the lookup through `resolveAndroidBinary` so the dep check sees an + // SDK install even when the binary is off PATH; otherwise a host with a + // working SDK would 424 with an "install adb"-style hint that doesn't + // describe the actual problem. + if (dep === "adb" || dep === "emulator") { + return (await resolveAndroidBinary(dep)) !== null; + } try { + // `command -v` via `/bin/sh` is POSIX-portable and doesn't invoke the dep + // itself — a bare `xcrun` call would fork the tool just to check existence, + // which is both slower and (for xcrun) can prompt the license agreement + // dialog on first use. await execFileAsync("/bin/sh", ["-c", `command -v ${dep}`], { timeout: 2_000 }); return true; } catch { @@ -50,7 +73,8 @@ async function isAvailable(dep: ToolDependency): Promise { /** * Throws DependencyMissingError if any declared dep isn't on PATH. All deps * are probed in parallel; the error message lists every missing one so the - * agent sees the complete picture on the first failure. + * agent sees the complete picture on the first failure instead of being + * prompted twice for the same tool. */ export async function ensureDeps(deps: readonly ToolDependency[]): Promise { if (deps.length === 0) return; @@ -61,17 +85,27 @@ export async function ensureDeps(deps: readonly ToolDependency[]): Promise throw new DependencyMissingError(missing, message); } -/** Single-dep helper for tools that branch on `classifyDevice`. */ +/** + * Single-dep convenience over `ensureDeps`. `dispatchByPlatform` already + * preflights the matched branch's `requires`; this is for tools that pick + * a platform path internally (e.g. `boot-device`, where there is no udid to + * classify yet) and want the same 424-with-install-hint failure mode. + */ export async function ensureDep(dep: ToolDependency): Promise { return ensureDeps([dep]); } -/** Test-only: clear the cache. */ +/** Test-only: clear the availability cache between tests. */ export function __resetDepCacheForTests(): void { cache.clear(); } -/** Test-only: pre-populate the cache so probe() is a no-op. */ +/** + * Test-only: pre-populate the cache so `ensureDep(dep)` is a no-op without + * shelling out. Needed by tool dispatch tests that assert on `execFile` call + * shapes / counts — without this, the `command -v ` probe appears as an + * extra first call and breaks `mock.calls[0]` expectations. + */ export function __primeDepCacheForTests(deps: ToolDependency[]): void { const now = Date.now(); for (const d of deps) cache.set(d, { available: true, checkedAt: now }); diff --git a/packages/tool-server/src/utils/cross-platform-tool.ts b/packages/tool-server/src/utils/cross-platform-tool.ts index ac3762df..6f922c7a 100644 --- a/packages/tool-server/src/utils/cross-platform-tool.ts +++ b/packages/tool-server/src/utils/cross-platform-tool.ts @@ -47,11 +47,16 @@ export interface PlatformImpl { * see real names (e.g. `services.simulatorServer`) instead of the raw * `Record` the registry hands in. */ -export function dispatchByPlatform(opts: { +export function dispatchByPlatform< + IosServices, + AndroidServices, + Params extends { udid: string }, + Result, +>(opts: { toolId: string; capability: ToolCapability; - ios: PlatformImpl; - android: PlatformImpl; + ios: PlatformImpl; + android: PlatformImpl; }): ( services: Record, params: Params, @@ -60,11 +65,20 @@ export function dispatchByPlatform { const device = resolveDevice(params.udid); assertSupported(opts.toolId, opts.capability, device); - const impl = device.platform === "ios" ? opts.ios : opts.android; - if (impl.requires?.length) { - await ensureDeps(impl.requires); + if (device.platform === "ios") { + if (opts.ios.requires?.length) { + await ensureDeps(opts.ios.requires); + } + return opts.ios.handler(services as unknown as IosServices, params, device, invokeOptions); } - const typedServices = services as unknown as Services; - return impl.handler(typedServices, params, device, invokeOptions); + if (opts.android.requires?.length) { + await ensureDeps(opts.android.requires); + } + return opts.android.handler( + services as unknown as AndroidServices, + params, + device, + invokeOptions + ); }; } 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/ios-profiler/IOS_PROFILER_REFERENCE.md b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md index 0d82812f..fc5779b7 100644 --- a/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md +++ b/packages/tool-server/src/utils/ios-profiler/IOS_PROFILER_REFERENCE.md @@ -2,7 +2,7 @@ A concise overview of how Argent's iOS profiling works: what native machinery is invoked, what gets captured, how the data is processed, and what comes back to the agent. -For the deeper "why" of each pipeline decision, see `PIPELINE_DESIGN.md`. For day-to-day workflow, see the `argent-ios-profiler` skill. +For the deeper "why" of each pipeline decision, see `PIPELINE_DESIGN.md`. For day-to-day workflow, see the `argent-native-profiler` skill. --- @@ -11,7 +11,7 @@ For the deeper "why" of each pipeline decision, see `PIPELINE_DESIGN.md`. For da Argent profiles iOS by driving Apple's own profiling stack (`xctrace`/Instruments) from a Node tool server, then post-processing the trace into an LLM-friendly markdown report. ``` -launch-app → ios-profiler-start → (user/agent interacts) → ios-profiler-stop → ios-profiler-analyze → profiler-stack-query (drill-down) / profiler-combined-report (with React) +launch-app → native-profiler-start → (user/agent interacts) → native-profiler-stop → native-profiler-analyze → profiler-stack-query (drill-down) / profiler-combined-report (with React) ``` Three concerns are captured: **CPU hotspots**, **UI hangs**, and **memory leaks**. Each is summarised, classified RED/YELLOW, and accompanied by app call chains for actionability. @@ -20,14 +20,14 @@ Three concerns are captured: **CPU hotspots**, **UI hangs**, and **memory leaks* ## 2. Native foundations -| Layer | What it is | How Argent uses it | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| **Instruments / `xctrace`** | Apple's tracing framework. `xctrace` is the headless CLI used by the Instruments app under the hood. | Every iOS profiling action is just an `xctrace record` / `xctrace export` invocation spawned by Argent. | -| **`.tracetemplate`** | A binary plist describing which Instruments to attach and how to configure them. | Argent ships `Argent.tracetemplate`, passed via `xctrace record --template`. | -| **`xcrun simctl`** | Simulator control. `simctl spawn ... launchctl list` enumerates running processes; `simctl listapps` enumerates installed bundles. | Used by `ios-profiler-start` to auto-detect the foreground user app (`CFBundleExecutable`) so the user does not have to specify it. | -| **Trace bundle (`.trace`)** | The package `xctrace record` produces. Internally a SQLite-backed structure with per-instrument tables. | `xctrace export --xpath ...` extracts individual tables to XML for parsing. | +| Layer | What it is | How Argent uses it | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Instruments / `xctrace`** | Apple's tracing framework. `xctrace` is the headless CLI used by the Instruments app under the hood. | Every iOS profiling action is just an `xctrace record` / `xctrace export` invocation spawned by Argent. | +| **`.tracetemplate`** | A binary plist describing which Instruments to attach and how to configure them. | Argent ships `Argent.tracetemplate`, passed via `xctrace record --template`. | +| **`xcrun simctl`** | Simulator control. `simctl spawn ... launchctl list` enumerates running processes; `simctl listapps` enumerates installed bundles. | Used by `native-profiler-start` to auto-detect the foreground user app (`CFBundleExecutable`) so the user does not have to specify it. | +| **Trace bundle (`.trace`)** | The package `xctrace record` produces. Internally a SQLite-backed structure with per-instrument tables. | `xctrace export --xpath ...` extracts individual tables to XML for parsing. | -`xctrace` runs via `child_process.spawn`. The PID is held in the per-device session blueprint (`IosProfilerSession`); SIGINT terminates recording cleanly so the trace bundle finalises. +`xctrace` runs via `child_process.spawn`. The PID is held in the per-device session blueprint (`NativeProfilerSession`); SIGINT terminates recording cleanly so the trace bundle finalises. --- @@ -50,9 +50,9 @@ Three concerns are captured: **CPU hotspots**, **UI hangs**, and **memory leaks* ## 4. The three tools (capture flow) -All three are wired through `ios-profiler-session` (per-device service, keyed by UDID). +All three are wired through `native-profiler-session` (per-device service, keyed by UDID). -### `ios-profiler-start` +### `native-profiler-start` 1. **Detect the target app** — runs `xcrun simctl spawn launchctl list`, parses `UIKitApplication:` lines, cross-references with `simctl listapps` to keep only `User` apps. Fails fast if zero or more than one app match. 2. **Resolve the template** — defaults to bundled `Argent.tracetemplate`, override via `template_path`. @@ -67,7 +67,7 @@ All three are wired through `ios-profiler-session` (per-device service, keyed by 4. **Start gating** — only resolves the tool call once `xctrace` prints `Starting recording` / `Ctrl-C to stop` on stdout. At that point Argent records `Date.now()` (`wallClockStartMs`) — the anchor used later for cross-tool time alignment. 5. **Safety timeout** — auto-SIGINTs after 10 minutes if `stop` is never called. -### `ios-profiler-stop` +### `native-profiler-stop` 1. `process.kill(xctracePid, "SIGINT")` and poll `process.kill(pid, 0)` until the child exits (xctrace needs a moment to finalise the trace bundle). 2. Run **schema-aware export**: @@ -81,7 +81,7 @@ All three are wired through `ios-profiler-session` (per-device service, keyed by - `_raw_leaks.xml` — `Leaks` track detail 4. Returns `{ traceFile, exportedFiles, exportDiagnostics }`. `exportDiagnostics` carries the discovered schema list and any per-stream errors so the agent can debug missing data. -### `ios-profiler-analyze` +### `native-profiler-analyze` Runs the post-processing pipeline, caches the parsed data on the session (so `profiler-stack-query` can reuse it), and renders the markdown report. Returns `{ report, reportFile, bottlenecksTotal }`. The full report is also written to `-report.md` so the agent can re-read it later without re-analysing. @@ -179,10 +179,10 @@ This is what makes before/after comparisons possible without keeping the origina ## 9. Cross-tool correlation: `profiler-combined-report` -When both `react-profiler-analyze` and `ios-profiler-analyze` ran on the same session, this tool aligns them on a shared **wall-clock anchor**: +When both `react-profiler-analyze` and `native-profiler-analyze` ran on the same session, this tool aligns them on a shared **wall-clock anchor**: - React Profiler stamps `Date.now()` at start and uses `performance.now()` ms internally. -- iOS Instruments uses trace-relative ns starting at 0; the wall-clock anchor is `wallClockStartMs` recorded by `ios-profiler-start`. +- iOS Instruments uses trace-relative ns starting at 0; the wall-clock anchor is `wallClockStartMs` recorded by `native-profiler-start`. - Helpers in `utils/profiler-shared/time-align.ts` convert in either direction. For each iOS hang the tool maps `[hangStart, hangEnd]` → wall-clock and looks for React commits whose `[timestamp, timestamp + totalRenderMs]` overlap (200 ms tolerance for jitter). The output report includes: @@ -204,7 +204,7 @@ For each iOS hang the tool maps `[hangStart, hangEnd]` → wall-clock and looks | UI hang | `microhang` | YELLOW | | Memory leak | any | RED | -> Note: `argent-ios-profiler/SKILL.md` currently documents the YELLOW band as 5–15 %. The implementation uses 3–15 % (`MIN_WEIGHT_PERCENTAGE = 3` in `02-aggregate.ts`). The code is the source of truth. +> Note: `argent-native-profiler/SKILL.md` currently documents the YELLOW band as 5–15 %. The implementation uses 3–15 % (`MIN_WEIGHT_PERCENTAGE = 3` in `02-aggregate.ts`). The code is the source of truth. --- @@ -222,11 +222,11 @@ For each iOS hang the tool maps `[hangStart, hangEnd]` → wall-clock and looks | Concern | File | | ---------------------------- | ----------------------------------------------------------------------------- | -| Tool definitions | `tools/profiler/ios-profiler/{start,stop,analyze}.ts` | +| Tool definitions | `tools/profiler/native-profiler/native-profiler-{start,stop,analyze}.ts` | | Drill-down query | `tools/profiler/query/profiler-stack-query.ts` | | Reload past sessions | `tools/profiler/query/profiler-load.ts` | | Cross-tool correlation | `tools/profiler/combined/profiler-combined-report.ts` | -| Per-device session state | `blueprints/ios-profiler-session.ts` | +| Per-device session state | `blueprints/native-profiler-session.ts` | | Bundled template | `utils/ios-profiler/Argent.tracetemplate` | | Schema-aware XML export | `utils/ios-profiler/export.ts` | | Pipeline (parser + 2 stages) | `utils/ios-profiler/pipeline/{xml-parser,01-correlate,02-aggregate,index}.ts` | @@ -234,4 +234,4 @@ For each iOS hang the tool maps `[hangStart, hangEnd]` → wall-clock and looks | RN/system filter signatures | `utils/ios-profiler/config.ts` | | Cross-tool time alignment | `utils/profiler-shared/time-align.ts` | | Design rationale | `utils/ios-profiler/PIPELINE_DESIGN.md` | -| User-facing workflow | `packages/argent/skills/argent-ios-profiler/SKILL.md` | +| User-facing workflow | `packages/skills/skills/argent-native-profiler/SKILL.md` | diff --git a/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md b/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md index 6bc0d6ae..3b3f787b 100644 --- a/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md +++ b/packages/tool-server/src/utils/ios-profiler/PIPELINE_DESIGN.md @@ -4,7 +4,7 @@ Living document tracking the reasoning behind pipeline architecture decisions. ## Architecture Overview -**3-tool flow**: `ios-profiler-start` → `ios-profiler-stop` → `ios-profiler-analyze` +**3-tool flow**: `native-profiler-start` → `native-profiler-stop` → `native-profiler-analyze` 1. **Start** — Detects the running app process on the simulator, spawns `xctrace record` attached to it. 2. **Stop** — Sends SIGINT to xctrace, waits for process exit, exports the `.trace` bundle to 3 XML files (CPU time-profile, potential-hangs, leaks). diff --git a/packages/tool-server/src/utils/ios-profiler/notify.ts b/packages/tool-server/src/utils/ios-profiler/notify.ts index 39a4341d..517e8d2a 100644 --- a/packages/tool-server/src/utils/ios-profiler/notify.ts +++ b/packages/tool-server/src/utils/ios-profiler/notify.ts @@ -22,7 +22,7 @@ export interface NotifyHandle { * * `notifyutil -v` only writes to stdout when the notification fires, so we * cannot detect the registration boundary by reading bytes. We use a fixed - * delay matching the timing proven in `ios-profiler-repro/05-notify-tracing-started.sh`. + * delay tuned empirically against `xctrace record` startup. * If `notifyutil` fails to spawn at all, `ready` rejects so callers can fall back. */ export function listenForDarwinNotification(name: string): NotifyHandle { diff --git a/packages/tool-server/src/utils/setup-registry.ts b/packages/tool-server/src/utils/setup-registry.ts index 253dde2e..324d2534 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/launch-app"; import { restartAppTool } from "../tools/restart-app"; import { reinstallAppTool } from "../tools/reinstall-app"; @@ -46,10 +46,10 @@ import { reactProfilerComponentSourceTool } from "../tools/profiler/react/react- import { reactProfilerCpuSummaryTool } from "../tools/profiler/react/react-profiler-cpu-summary"; import { reactProfilerRendersTool } from "../tools/profiler/react/react-profiler-renders"; import { reactProfilerFiberTreeTool } from "../tools/profiler/react/react-profiler-fiber-tree"; -import { iosInstrumentsStartTool } from "../tools/profiler/ios-profiler/ios-profiler-start"; -import { iosInstrumentsStopTool } from "../tools/profiler/ios-profiler/ios-profiler-stop"; -import { iosInstrumentsAnalyzeTool } from "../tools/profiler/ios-profiler/ios-profiler-analyze"; -import { iosInstrumentsSessionBlueprint } from "../blueprints/ios-profiler-session"; +import { nativeProfilerStartTool } from "../tools/profiler/native-profiler/native-profiler-start"; +import { nativeProfilerStopTool } from "../tools/profiler/native-profiler/native-profiler-stop"; +import { nativeProfilerAnalyzeTool } from "../tools/profiler/native-profiler/native-profiler-analyze"; +import { nativeProfilerSessionBlueprint } from "../blueprints/native-profiler-session"; import { profilerCpuQueryTool } from "../tools/profiler/query/profiler-cpu-query"; import { profilerCommitQueryTool } from "../tools/profiler/query/profiler-commit-query"; import { profilerStackQueryTool } from "../tools/profiler/query/profiler-stack-query"; @@ -75,12 +75,12 @@ export function createRegistry(): Registry { registry.registerBlueprint(jsRuntimeDebuggerBlueprint); registry.registerBlueprint(networkInspectorBlueprint); registry.registerBlueprint(reactProfilerSessionBlueprint); - registry.registerBlueprint(iosInstrumentsSessionBlueprint); + registry.registerBlueprint(nativeProfilerSessionBlueprint); 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); @@ -113,9 +113,9 @@ export function createRegistry(): Registry { registry.registerTool(reactProfilerCpuSummaryTool); registry.registerTool(reactProfilerRendersTool); registry.registerTool(reactProfilerFiberTreeTool); - registry.registerTool(iosInstrumentsStartTool); - registry.registerTool(iosInstrumentsStopTool); - registry.registerTool(iosInstrumentsAnalyzeTool); + registry.registerTool(nativeProfilerStartTool); + registry.registerTool(nativeProfilerStopTool); + registry.registerTool(nativeProfilerAnalyzeTool); registry.registerTool(profilerCpuQueryTool); registry.registerTool(profilerCommitQueryTool); registry.registerTool(profilerStackQueryTool); diff --git a/packages/tool-server/src/utils/simulator-watcher.ts b/packages/tool-server/src/utils/simulator-watcher.ts index 0ce3a467..8e4b0fa9 100644 --- a/packages/tool-server/src/utils/simulator-watcher.ts +++ b/packages/tool-server/src/utils/simulator-watcher.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { Registry } from "@argent/registry"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../blueprints/native-devtools"; +import { NATIVE_DEVTOOLS_NAMESPACE, nativeDevtoolsRef } from "../blueprints/native-devtools"; const execFileAsync = promisify(execFile); @@ -28,7 +28,8 @@ async function initSimulator( ): Promise { watchedUdids.add(udid); try { - await registry.resolveService(`${NATIVE_DEVTOOLS_NAMESPACE}:${udid}`); + const ndRef = nativeDevtoolsRef({ id: udid, platform: "ios", kind: "simulator" }); + await registry.resolveService(ndRef.urn, ndRef.options); } catch (err) { // Service failed to start (e.g. simulator shut down mid-init); retry next tick watchedUdids.delete(udid); diff --git a/packages/tool-server/src/utils/workspace-reader.ts b/packages/tool-server/src/utils/workspace-reader.ts index 7f1e0f14..6e04bfe1 100644 --- a/packages/tool-server/src/utils/workspace-reader.ts +++ b/packages/tool-server/src/utils/workspace-reader.ts @@ -1,5 +1,5 @@ import { readFile, readdir, stat, access } from "node:fs/promises"; -import { join, basename } from "node:path"; +import { join } from "node:path"; import { execFile } from "node:child_process"; // ── Types ──────────────────────────────────────────────────────────── @@ -24,7 +24,8 @@ export interface WorkspaceSnapshot { has_ios_dir: boolean; has_android_dir: boolean; ios_workspace: string | null; - has_podfile: boolean; + ios_has_podfile: boolean; + android_has_gradle: boolean; lockfile: "yarn.lock" | "package-lock.json" | "pnpm-lock.yaml" | "bun.lockb" | "bun.lock" | null; @@ -366,12 +367,15 @@ export async function readWorkspaceSnapshot(workspacePath: string): Promise { + 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 result = execFileMock(cmd, args); + if (result instanceof Error) { + // Mirror execFile's actual rejection contract: stderr/stdout/signal/killed + // are all attached to the error object so describeAdbFailure can read them. + const e = result as Error & { stderr?: string; stdout?: string }; + callback(e, { stdout: e.stdout ?? "", stderr: e.stderr ?? "" }); + } else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +vi.mock("../src/utils/android-binary", () => ({ + resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name), + __resetAndroidBinaryCacheForTesting: () => {}, +})); + +import { runAdb } from "../src/utils/adb"; + +beforeEach(() => { + execFileMock.mockReset(); +}); + +// Timeout-SIGKILL leaves empty stderr/stdout and a bare "Command failed: " +// message; without surfacing signal/killed/code the cause is invisible. +describe("describeAdbFailure surfaces timeout/SIGKILL metadata", () => { + async function expectRejection(args: string[]): Promise { + try { + await runAdb(args); + throw new Error("expected rejection"); + } catch (err) { + return (err as Error).message; + } + } + + it("appends signal=SIGKILL and killed=true when stderr/stdout are empty", async () => { + execFileMock.mockImplementation(() => + Object.assign(new Error("Command failed: adb -s emulator-5554 shell wm size"), { + signal: "SIGKILL" as const, + killed: true, + code: null, + stdout: "", + stderr: "", + }) + ); + const msg = await expectRejection(["-s", "emulator-5554", "shell", "wm size"]); + expect(msg).toMatch(/killed=true/); + expect(msg).toMatch(/signal=SIGKILL/); + }); + + it("preserves stderr-driven message when adb did emit a diagnostic", async () => { + execFileMock.mockImplementation(() => + Object.assign(new Error("Command failed: adb -s emulator-5554 shell wm size"), { + stderr: "error: device 'emulator-5554' offline", + stdout: "", + code: 1, + }) + ); + const msg = await expectRejection(["-s", "emulator-5554", "shell", "wm size"]); + expect(msg).toContain("error: device 'emulator-5554' offline"); + // stderr path must not be polluted with the suffix + expect(msg).not.toMatch(/\(.*code=/); + }); + + it("appends non-zero exit code when the child exited cleanly without stderr", async () => { + execFileMock.mockImplementation(() => + Object.assign(new Error("Command failed: adb -s X shell foo"), { + code: 127, + stdout: "", + stderr: "", + }) + ); + const msg = await expectRejection(["-s", "X", "shell", "foo"]); + expect(msg).toMatch(/\(code=127\)/); + }); + + it("surfaces spawn-error string codes (ENOENT) when nothing else is present", async () => { + execFileMock.mockImplementation(() => + Object.assign(new Error("spawn adb ENOENT"), { + code: "ENOENT", + syscall: "spawn adb", + path: "adb", + }) + ); + const msg = await expectRejection(["devices"]); + expect(msg).toMatch(/\(code=ENOENT\)/); + }); + + it("emits no empty () suffix when no signal/killed/code is set", async () => { + execFileMock.mockImplementation(() => + Object.assign(new Error("boom"), { stdout: "", stderr: "" }) + ); + const msg = await expectRejection(["devices"]); + expect(msg).toBe("adb devices failed: boom"); + }); +}); diff --git a/packages/tool-server/test/boot-simulator.test.ts b/packages/tool-server/test/boot-simulator.test.ts deleted file mode 100644 index 3956d43e..00000000 --- a/packages/tool-server/test/boot-simulator.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Registry } from "@argent/registry"; - -type ExecFileCallback = (error: Error | null, stdout?: string, stderr?: string) => void; - -const mockExecFile = vi.fn(); - -function getCallback(args: unknown[]): ExecFileCallback { - const callback = args[args.length - 1]; - if (typeof callback !== "function") { - throw new Error("Missing execFile callback"); - } - return callback as ExecFileCallback; -} - -vi.mock("node:child_process", () => ({ - execFile: (...args: unknown[]) => mockExecFile(...args), -})); - -import { createBootSimulatorTool } from "../src/tools/simulator/boot-simulator"; - -describe("boot-simulator tool", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockExecFile.mockImplementation((...args: unknown[]) => { - getCallback(args)(null, "", ""); - return {} as never; - }); - }); - - it("waits for boot completion and native-devtools init before returning", async () => { - const resolveService = vi.fn(async () => {}); - const registry = { - resolveService, - } as unknown as Registry; - - const tool = createBootSimulatorTool(registry); - - await expect(tool.execute!({}, { udid: "SIM-1" })).resolves.toEqual({ - udid: "SIM-1", - 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"]], - ["open", ["-a", "Simulator.app"]], - ]); - expect(resolveService).toHaveBeenCalledWith("NativeDevtools:SIM-1"); - expect(resolveService.mock.invocationCallOrder[0]).toBeGreaterThan( - mockExecFile.mock.invocationCallOrder[1] - ); - expect(resolveService.mock.invocationCallOrder[0]).toBeLessThan( - mockExecFile.mock.invocationCallOrder[2] - ); - }); - - it("still primes native-devtools when simctl reports the simulator is already booted", async () => { - mockExecFile - .mockImplementationOnce((...args: unknown[]) => { - getCallback(args)(new Error("Unable to boot device in current state: Booted")); - return {} as never; - }) - .mockImplementation((...args: unknown[]) => { - getCallback(args)(null, "", ""); - return {} as never; - }); - - const resolveService = vi.fn(async () => {}); - const registry = { resolveService } as unknown as Registry; - - const tool = createBootSimulatorTool(registry); - - await expect(tool.execute!({}, { udid: "SIM-2" })).resolves.toEqual({ - udid: "SIM-2", - booted: true, - }); - - expect(mockExecFile.mock.calls[1]?.slice(0, 2)).toEqual([ - "xcrun", - ["simctl", "bootstatus", "SIM-2", "-b"], - ]); - expect(resolveService).toHaveBeenCalledWith("NativeDevtools:SIM-2"); - }); -}); diff --git a/packages/tool-server/test/capability.test.ts b/packages/tool-server/test/capability.test.ts index 9b72b461..f09a0f49 100644 --- a/packages/tool-server/test/capability.test.ts +++ b/packages/tool-server/test/capability.test.ts @@ -47,28 +47,28 @@ describe("assertSupported", () => { describe("NotImplementedOnPlatformError", () => { it("composes a uniform message with toolId, platform, and the file path to fill in", () => { const err = new NotImplementedOnPlatformError({ - toolId: "gesture-tap", + toolId: "demo-tool", platform: "android", - hint: "Use `adb shell input tap `.", + hint: "Use `adb shell `.", }); expect(err.name).toBe("NotImplementedOnPlatformError"); - expect(err.toolId).toBe("gesture-tap"); + expect(err.toolId).toBe("demo-tool"); expect(err.platform).toBe("android"); - expect(err.hint).toBe("Use `adb shell input tap `."); - expect(err.message).toContain("gesture-tap"); + expect(err.hint).toBe("Use `adb shell `."); + expect(err.message).toContain("demo-tool"); expect(err.message).toContain("android"); - expect(err.message).toContain("tools/gesture-tap/platforms/android.ts"); + expect(err.message).toContain("tools/demo-tool/platforms/android.ts"); expect(err.message).toContain("capability declaration"); - expect(err.message).toContain("Use `adb shell input tap"); + expect(err.message).toContain("Use `adb shell"); }); it("works without a hint", () => { const err = new NotImplementedOnPlatformError({ - toolId: "screenshot", + toolId: "demo-tool", platform: "android", }); expect(err.hint).toBeNull(); - expect(err.message).toContain("screenshot"); + expect(err.message).toContain("demo-tool"); expect(err.message).toContain("android"); }); }); diff --git a/packages/tool-server/test/describe-ax-adapter.test.ts b/packages/tool-server/test/describe-ax-adapter.test.ts index 71e85181..a3e35d76 100644 --- a/packages/tool-server/test/describe-ax-adapter.test.ts +++ b/packages/tool-server/test/describe-ax-adapter.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { adaptAXElement, adaptAXDescribeToDescribeResult, -} from "../src/tools/describe/platforms/ios-ax-adapter"; +} from "../src/tools/describe/platforms/ios/ios-ax-adapter"; import type { AXDescribeResponse } from "../src/blueprints/ax-service"; describe("describe ax-service adapter", () => { diff --git a/packages/tool-server/test/describe-native-adapter.test.ts b/packages/tool-server/test/describe-native-adapter.test.ts index 8963d64d..9d3e53c0 100644 --- a/packages/tool-server/test/describe-native-adapter.test.ts +++ b/packages/tool-server/test/describe-native-adapter.test.ts @@ -3,7 +3,7 @@ import { adaptNativeDescribeElementToDescribeNode, adaptNativeDescribeToDescribeResult, mapNativeTraitsToDescribeRole, -} from "../src/tools/describe/platforms/ios-native-adapter"; +} from "../src/tools/describe/platforms/ios/ios-native-adapter"; import type { NativeDescribeScreenResult } from "../src/tools/native-devtools/native-describe-contract"; describe("describe native adapter", () => { diff --git a/packages/tool-server/test/describe-tool.test.ts b/packages/tool-server/test/describe-tool.test.ts index e2375853..56c703eb 100644 --- a/packages/tool-server/test/describe-tool.test.ts +++ b/packages/tool-server/test/describe-tool.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AXServiceApi, AXDescribeResponse } from "../src/blueprints/ax-service"; import type { NativeDevtoolsApi } from "../src/blueprints/native-devtools"; import { createDescribeTool } from "../src/tools/describe"; +import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps"; function makeAXServiceApi(response: AXDescribeResponse): AXServiceApi { return { @@ -66,6 +67,15 @@ function makeMockRegistry(options: { } describe("describe tool", () => { + beforeEach(() => { + // `describe` dispatches by udid shape (classifyDevice). The tests pass + // iOS-shape udids that route to the iOS branch, whose `requires:["xcrun"]` + // would shell out to probe PATH on Linux CI without xcrun. Prime the dep + // cache so neither branch probes — handlers run with mock services. + __resetDepCacheForTests(); + __primeDepCacheForTests(["xcrun", "adb"]); + }); + it("returns elements from ax-service daemon", async () => { const axApi = makeAXServiceApi({ alertVisible: false, @@ -302,7 +312,8 @@ describe("describe tool", () => { await tool.execute({}, { udid: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" }); expect(registry.resolveService).toHaveBeenCalledWith( - "AXService:BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + "AXService:BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + { device: { id: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", platform: "ios", kind: "simulator" } } ); }); diff --git a/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts b/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts index 62e0b528..e515934e 100644 --- a/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts +++ b/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "events"; import { - iosInstrumentsSessionBlueprint, - type IosProfilerSessionApi, -} from "../../src/blueprints/ios-profiler-session"; + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, +} from "../../src/blueprints/native-profiler-session"; // xctrace's verbatim cold-launch race signature, asserted on as a stable -// upstream contract by the retry detector in ios-profiler-start. +// upstream contract by the retry detector in native-profiler-start. const COLD_START_ERROR = "xctrace record exited before recording started (code=19, signal=null). " + "stderr: Cannot find process matching name: MyApp"; @@ -20,12 +20,13 @@ class StartFakeChild extends EventEmitter { kill = vi.fn(); } -async function buildSession(): Promise { - const instance = await iosInstrumentsSessionBlueprint.factory({}, "DEVICE-UDID"); +async function buildSession(): Promise { + const device = { id: "DEVICE-UDID", platform: "ios" as const, kind: "simulator" as const }; + const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device }); return instance.api; } -describe("ios-profiler-start cold-start retry", () => { +describe("native-profiler-start cold-start retry", () => { beforeEach(() => { vi.resetModules(); vi.useFakeTimers(); @@ -62,8 +63,8 @@ describe("ios-profiler-start cold-start retry", () => { waitForXctraceReady: waitForReady, })); - const { iosInstrumentsStartTool: startTool } = - await import("../../src/tools/profiler/ios-profiler/ios-profiler-start"); + const { nativeProfilerStartTool: startTool } = + await import("../../src/tools/profiler/native-profiler/native-profiler-start"); const api = await buildSession(); const promise = startTool.execute({ session: api } as never, { @@ -103,8 +104,8 @@ describe("ios-profiler-start cold-start retry", () => { waitForXctraceReady: waitForReady, })); - const { iosInstrumentsStartTool: startTool } = - await import("../../src/tools/profiler/ios-profiler/ios-profiler-start"); + const { nativeProfilerStartTool: startTool } = + await import("../../src/tools/profiler/native-profiler/native-profiler-start"); const api = await buildSession(); const promise = startTool @@ -149,8 +150,8 @@ describe("ios-profiler-start cold-start retry", () => { waitForXctraceReady: waitForReady, })); - const { iosInstrumentsStartTool: startTool } = - await import("../../src/tools/profiler/ios-profiler/ios-profiler-start"); + const { nativeProfilerStartTool: startTool } = + await import("../../src/tools/profiler/native-profiler/native-profiler-start"); const api = await buildSession(); await expect( diff --git a/packages/tool-server/test/ios-instruments/dispose.test.ts b/packages/tool-server/test/ios-instruments/dispose.test.ts index fec2be2e..487dabf2 100644 --- a/packages/tool-server/test/ios-instruments/dispose.test.ts +++ b/packages/tool-server/test/ios-instruments/dispose.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "events"; import type { ChildProcess } from "child_process"; import { - iosInstrumentsSessionBlueprint, - type IosProfilerSessionApi, -} from "../../src/blueprints/ios-profiler-session"; + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, +} from "../../src/blueprints/native-profiler-session"; // Minimal ChildProcess fake — same shape as the lifecycle tests. class FakeChild extends EventEmitter { @@ -32,14 +32,15 @@ class FakeChild extends EventEmitter { const asChild = (c: FakeChild): ChildProcess => c as unknown as ChildProcess; async function buildSession(): Promise<{ - api: IosProfilerSessionApi; + api: NativeProfilerSessionApi; dispose: () => Promise; }> { - const instance = await iosInstrumentsSessionBlueprint.factory({}, "DEVICE-UDID"); + const device = { id: "DEVICE-UDID", platform: "ios" as const, kind: "simulator" as const }; + const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device }); return { api: instance.api, dispose: instance.dispose }; } -describe("iosInstrumentsSessionBlueprint dispose", () => { +describe("nativeProfilerSessionBlueprint dispose", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); diff --git a/packages/tool-server/test/ios-instruments/stop-recovery.test.ts b/packages/tool-server/test/ios-instruments/stop-recovery.test.ts index e6ba10f9..8b90de83 100644 --- a/packages/tool-server/test/ios-instruments/stop-recovery.test.ts +++ b/packages/tool-server/test/ios-instruments/stop-recovery.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "events"; import type { ChildProcess } from "child_process"; import { - iosInstrumentsSessionBlueprint, - type IosProfilerSessionApi, -} from "../../src/blueprints/ios-profiler-session"; + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, +} from "../../src/blueprints/native-profiler-session"; // Mock the trace exporter so tests don't shell out to xctrace or touch disk. vi.mock("../../src/utils/ios-profiler/export", () => ({ @@ -12,8 +12,8 @@ vi.mock("../../src/utils/ios-profiler/export", () => ({ })); import { exportIosTraceData } from "../../src/utils/ios-profiler/export"; -import { iosInstrumentsStopTool } from "../../src/tools/profiler/ios-profiler/ios-profiler-stop"; -import { handleXctraceExit } from "../../src/tools/profiler/ios-profiler/ios-profiler-start"; +import { nativeProfilerStopTool } from "../../src/tools/profiler/native-profiler/native-profiler-stop"; +import { handleXctraceExit } from "../../src/tools/profiler/native-profiler/native-profiler-start"; const mockedExport = vi.mocked(exportIosTraceData); @@ -41,8 +41,9 @@ class FakeChild extends EventEmitter { const asChild = (c: FakeChild): ChildProcess => c as unknown as ChildProcess; -async function buildSession(): Promise { - const instance = await iosInstrumentsSessionBlueprint.factory({}, "DEVICE-UDID"); +async function buildSession(): Promise { + const device = { id: "DEVICE-UDID", platform: "ios" as const, kind: "simulator" as const }; + const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device }); return instance.api; } @@ -95,7 +96,7 @@ describe("handleXctraceExit", () => { }); }); -describe("ios-profiler-stop recovery branch", () => { +describe("native-profiler-stop recovery branch", () => { beforeEach(() => { mockedExport.mockReset(); mockedExport.mockReturnValue(FAKE_EXPORT_RESULT); @@ -111,7 +112,7 @@ describe("ios-profiler-stop recovery branch", () => { api.recordingExitedUnexpectedly = true; api.lastExitInfo = { code: 0, signal: null }; - const result = await iosInstrumentsStopTool.execute({ session: api } as never, { + const result = await nativeProfilerStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID", }); @@ -130,7 +131,7 @@ describe("ios-profiler-stop recovery branch", () => { api.recordingExitedUnexpectedly = true; api.lastExitInfo = { code: 137, signal: "SIGKILL" }; - const result = await iosInstrumentsStopTool.execute({ session: api } as never, { + const result = await nativeProfilerStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID", }); @@ -144,7 +145,7 @@ describe("ios-profiler-stop recovery branch", () => { api.recordingTimedOut = true; api.recordingExitedUnexpectedly = false; - const result = await iosInstrumentsStopTool.execute({ session: api } as never, { + const result = await nativeProfilerStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID", }); @@ -158,8 +159,8 @@ describe("ios-profiler-stop recovery branch", () => { api.traceFile = FAKE_TRACE; // a stale traceFile alone must not trigger recovery await expect( - iosInstrumentsStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID" }) - ).rejects.toThrow("No active iOS profiling session found"); + nativeProfilerStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID" }) + ).rejects.toThrow("No active native profiling session found"); expect(mockedExport).not.toHaveBeenCalled(); }); @@ -167,8 +168,10 @@ describe("ios-profiler-stop recovery branch", () => { const api = await buildSession(); await expect( - iosInstrumentsStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID" }) - ).rejects.toThrow("No active iOS profiling session found. Call ios-profiler-start first."); + nativeProfilerStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID" }) + ).rejects.toThrow( + "No active native profiling session found. Call native-profiler-start first." + ); expect(mockedExport).not.toHaveBeenCalled(); }); @@ -189,7 +192,7 @@ describe("ios-profiler-stop recovery branch", () => { api.xctraceProcess = asChild(child); api.traceFile = FAKE_TRACE; - const promise = iosInstrumentsStopTool.execute({ session: api } as never, { + const promise = nativeProfilerStopTool.execute({ session: api } as never, { device_id: "DEVICE-UDID", }); await vi.advanceTimersByTimeAsync(10); @@ -204,7 +207,7 @@ describe("ios-profiler-stop recovery branch", () => { }); }); -describe("ios-profiler-start fresh-start reset", () => { +describe("native-profiler-start fresh-start reset", () => { beforeEach(() => { vi.resetModules(); vi.useFakeTimers(); @@ -246,8 +249,8 @@ describe("ios-profiler-start fresh-start reset", () => { waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })), })); - const { iosInstrumentsStartTool: startTool } = - await import("../../src/tools/profiler/ios-profiler/ios-profiler-start"); + const { nativeProfilerStartTool: startTool } = + await import("../../src/tools/profiler/native-profiler/native-profiler-start"); const api = await buildSession(); // Pre-populate stale state from a prior aborted run. diff --git a/packages/tool-server/test/workspace-reader-integration.test.ts b/packages/tool-server/test/workspace-reader-integration.test.ts index 27dfaf65..25f61af8 100644 --- a/packages/tool-server/test/workspace-reader-integration.test.ts +++ b/packages/tool-server/test/workspace-reader-integration.test.ts @@ -235,7 +235,7 @@ describe("workspace-reader integration (realistic RN project)", () => { expect(snap.has_ios_dir).toBe(true); expect(snap.has_android_dir).toBe(true); expect(snap.ios_workspace).toBe("IntegrationTestApp.xcworkspace"); - expect(snap.has_podfile).toBe(true); + expect(snap.ios_has_podfile).toBe(true); // Lockfile expect(snap.lockfile).toBe("yarn.lock"); diff --git a/packages/tool-server/test/workspace-reader.test.ts b/packages/tool-server/test/workspace-reader.test.ts index 4af27d36..1ce3ac9c 100644 --- a/packages/tool-server/test/workspace-reader.test.ts +++ b/packages/tool-server/test/workspace-reader.test.ts @@ -187,7 +187,7 @@ module.exports = getDefaultConfig(__dirname);` expect(snap.metro_config_raw).toContain("getDefaultConfig"); expect(snap.has_ios_dir).toBe(true); expect(snap.has_android_dir).toBe(true); - expect(snap.has_podfile).toBe(true); + expect(snap.ios_has_podfile).toBe(true); expect(snap.lockfile).toBe("yarn.lock"); expect(snap.config_files_found).toContain("metro.config.js"); }); @@ -224,7 +224,8 @@ module.exports = getDefaultConfig(__dirname);` expect(snap.has_ios_dir).toBe(false); expect(snap.has_android_dir).toBe(false); expect(snap.ios_workspace).toBeNull(); - expect(snap.has_podfile).toBe(false); + expect(snap.ios_has_podfile).toBe(false); + expect(snap.android_has_gradle).toBe(false); expect(snap.lockfile).toBeNull(); expect(snap.env_files).toEqual([]); expect(snap.scripts_dir_entries).toBeNull(); @@ -235,6 +236,25 @@ module.exports = getDefaultConfig(__dirname);` expect(snap.config_files_found).toEqual([]); }); + it("detects android/gradlew wrapper presence", async () => { + await writeJson(tempDir, "package.json", { name: "AndroidApp" }); + await mkdirIn(tempDir, "android"); + await writeFile(join(tempDir, "android", "gradlew"), "#!/usr/bin/env sh\n"); + + const snap = await readWorkspaceSnapshot(tempDir); + expect(snap.has_android_dir).toBe(true); + expect(snap.android_has_gradle).toBe(true); + }); + + it("reports android_has_gradle=false when gradlew is missing", async () => { + await writeJson(tempDir, "package.json", { name: "AndroidNoGradle" }); + await mkdirIn(tempDir, "android/app"); + + const snap = await readWorkspaceSnapshot(tempDir); + expect(snap.has_android_dir).toBe(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 } };`);