From 02aa92554deac2c5b63f8681227e85efd676b7b4 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 12:19:10 +0100 Subject: [PATCH 01/10] feat: add ToolScope DSL and ToolRegistrar interface for modular tool registration --- .../com/example/visiontest/tools/ToolDsl.kt | 68 ++++++++++++ .../example/visiontest/tools/ToolRegistrar.kt | 5 + .../refactor-toolfactory/.openspec.yaml | 2 + .../changes/refactor-toolfactory/design.md | 97 +++++++++++++++++ .../changes/refactor-toolfactory/proposal.md | 31 ++++++ .../specs/tool-discovery/spec.md | 94 ++++++++++++++++ .../specs/tool-registration-dsl/spec.md | 103 ++++++++++++++++++ .../changes/refactor-toolfactory/tasks.md | 37 +++++++ 8 files changed, 437 insertions(+) create mode 100644 app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt create mode 100644 app/src/main/kotlin/com/example/visiontest/tools/ToolRegistrar.kt create mode 100644 openspec/changes/refactor-toolfactory/.openspec.yaml create mode 100644 openspec/changes/refactor-toolfactory/design.md create mode 100644 openspec/changes/refactor-toolfactory/proposal.md create mode 100644 openspec/changes/refactor-toolfactory/specs/tool-discovery/spec.md create mode 100644 openspec/changes/refactor-toolfactory/specs/tool-registration-dsl/spec.md create mode 100644 openspec/changes/refactor-toolfactory/tasks.md diff --git a/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt new file mode 100644 index 0000000..897fece --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt @@ -0,0 +1,68 @@ +package com.example.visiontest.tools + +import com.example.visiontest.utils.ErrorHandler +import io.modelcontextprotocol.kotlin.sdk.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.Tool +import io.modelcontextprotocol.kotlin.sdk.server.Server +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.jsonPrimitive +import org.slf4j.Logger + +/** + * DSL scope for registering MCP tools with standardized timeout and error handling. + * + * Absorbs the repeated try/withTimeout/catch/handleToolError boilerplate + * so each tool only provides name, description, schema, and the business logic. + * + * Since the MCP SDK's `addTool` handler is already a suspend function, + * we use `withTimeout` directly — no `runBlocking` needed. + */ +class ToolScope( + private val server: Server, + private val logger: Logger, + private val defaultTimeoutMs: Long = 10_000L +) { + fun tool( + name: String, + description: String, + inputSchema: Tool.Input = Tool.Input(), + timeoutMs: Long = defaultTimeoutMs, + handler: suspend (CallToolRequest) -> String + ) { + server.addTool(name, description, inputSchema) { request -> + try { + val result = withTimeout(timeoutMs) { + handler(request) + } + CallToolResult(content = listOf(TextContent(result))) + } catch (e: Exception) { + ErrorHandler.handleToolError(e, logger, name) + } + } + } +} + +// --- CallToolRequest extension helpers --- + +fun CallToolRequest.requireString(key: String): String { + return this.arguments[key]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Missing required parameter '$key'") +} + +fun CallToolRequest.requireInt(key: String): Int { + val raw = this.requireString(key) + return raw.toIntOrNull() + ?: throw IllegalArgumentException("Parameter '$key' must be an integer, got '$raw'") +} + +fun CallToolRequest.optionalString(key: String): String? { + return this.arguments[key]?.jsonPrimitive?.content +} + +fun CallToolRequest.optionalInt(key: String): Int? { + val raw = this.optionalString(key) ?: return null + return raw.toIntOrNull() + ?: throw IllegalArgumentException("Parameter '$key' must be an integer, got '$raw'") +} diff --git a/app/src/main/kotlin/com/example/visiontest/tools/ToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/ToolRegistrar.kt new file mode 100644 index 0000000..c9ceea0 --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/tools/ToolRegistrar.kt @@ -0,0 +1,5 @@ +package com.example.visiontest.tools + +interface ToolRegistrar { + fun registerTools(scope: ToolScope) +} diff --git a/openspec/changes/refactor-toolfactory/.openspec.yaml b/openspec/changes/refactor-toolfactory/.openspec.yaml new file mode 100644 index 0000000..0a32546 --- /dev/null +++ b/openspec/changes/refactor-toolfactory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-23 diff --git a/openspec/changes/refactor-toolfactory/design.md b/openspec/changes/refactor-toolfactory/design.md new file mode 100644 index 0000000..c89781c --- /dev/null +++ b/openspec/changes/refactor-toolfactory/design.md @@ -0,0 +1,97 @@ +## Context + +`ToolFactory.kt` (1975 lines) is the sole class responsible for registering all 36 MCP tools, managing timeouts/errors, extracting request parameters, discovering platform assets (APKs, Xcode projects, xctestrun bundles), and resolving project paths. Every tool follows the same boilerplate: `server.addTool(…) { try { runWithTimeout { … } } catch { handleToolError(…) } }`. The file is still functional but increasingly painful to navigate and extend. + +The codebase already follows a clean package structure (`android/`, `ios/`, `config/`, `common/`, `utils/`), so introducing `tools/` and `discovery/` packages is a natural extension. + +## Goals / Non-Goals + +**Goals:** +- Split `ToolFactory.kt` into cohesive, single-responsibility files under 500 lines each +- Eliminate the repeated try/timeout/catch boilerplate from every tool registration via a DSL +- Eliminate the repeated `request.arguments["x"]?.jsonPrimitive?.content` parameter extraction via extension helpers +- Make discovery logic independently testable without instantiating a full `ToolFactory` +- Keep the public API identical (`ToolFactory(android, ios, logger).registerAllTools(server)`) + +**Non-Goals:** +- Changing any MCP tool name, description, schema, or behavior +- Introducing new library dependencies +- Refactoring the `AutomationClient`, `IOSAutomationClient`, or other existing classes +- Introducing dependency injection frameworks (Koin, Dagger, etc.) +- Changing the coroutine strategy (`runBlocking` + `withTimeout`) + +## Decisions + +### 1. ToolScope DSL over extension functions + +**Decision:** Create a `ToolScope` class that wraps `Server` and provides a `tool()` function absorbing the boilerplate. + +**Alternatives considered:** +- **Extension functions on Server**: Would require making `ToolFactory` members `internal`, and the error handling + timeout logic would need to be passed as parameters or duplicated. Less encapsulated. +- **Abstract base class**: Kotlin prefers composition over inheritance; a base class would force a single inheritance chain on registrars. + +**Rationale:** `ToolScope` encapsulates `Server`, `Logger`, and default timeout in one object. The `tool()` function provides a clean DSL where each tool only supplies name, description, schema, and the actual logic. The boilerplate (try/catch, `runBlocking`, `withTimeout`, `CallToolResult` wrapping) lives in exactly one place. + +### 2. ToolRegistrar interface with 4 implementations + +**Decision:** One interface, four classes split by platform × responsibility. + +| Class | Tools | Primary dependency | +|-------|-------|--------------------| +| `AndroidDeviceToolRegistrar` | 4 | `DeviceConfig` | +| `AndroidAutomationToolRegistrar` | 14 | `AutomationClient`, `ToolDiscovery` | +| `IOSDeviceToolRegistrar` | 4 | `DeviceConfig` | +| `IOSAutomationToolRegistrar` | 10 | `IOSAutomationClient`, `ToolDiscovery` | + +**Alternatives considered:** +- **2 classes (Android + iOS)**: The automation registrars are ~500 lines; combining device + automation would still be ~600+ lines per platform. +- **1 class per tool**: 36 files is too granular — the registration logic per tool is only 10-15 lines with the DSL. + +**Rationale:** 4 registrars hit the sweet spot: each file is 120-500 lines, each has clear cohesion, and the split matches the existing `android/` vs `ios/` package structure. + +### 3. CallToolRequest extension helpers + +**Decision:** Add extension functions on `CallToolRequest?` for parameter extraction: + +```kotlin +fun CallToolRequest?.requireString(key: String): String +fun CallToolRequest?.requireInt(key: String): Int +fun CallToolRequest?.optionalString(key: String): String? +fun CallToolRequest?.optionalInt(key: String): Int? +``` + +These return the value or throw `IllegalArgumentException` (for required) which the DSL's catch block maps to an error response. + +**Rationale:** The pattern `request?.arguments?.get("x")?.jsonPrimitive?.content ?: return@runWithTimeout "Error: Missing 'x'"` appears dozens of times. Extension helpers reduce each parameter extraction to one line and standardize error messages. + +### 4. ToolDiscovery as a standalone class in `discovery/` package + +**Decision:** Extract all path-resolution functions to `discovery/ToolDiscovery.kt` as a class taking only `Logger`. + +**Alternatives considered:** +- **Top-level functions**: Would work but loses the ability to inject a logger and would scatter functions across the package. +- **Object singleton**: Can't inject dependencies (logger, test overrides for `System.getenv`). + +**Rationale:** `ToolDiscovery(logger)` is independently constructable and testable. Tests no longer need mock `DeviceConfig` objects. The class groups 12 related functions that all do filesystem/environment resolution. + +### 5. ToolHelpers as an object (not a class) + +**Decision:** `extractProperty`, `extractPattern`, `formatAppInfo` become functions on a `ToolHelpers` object. + +**Rationale:** These are pure functions with zero state. A Kotlin `object` is the idiomatic way to namespace stateless utilities. Tests call `ToolHelpers.extractProperty(…)` directly — no construction needed. + +### 6. iOS process state stays in IOSAutomationToolRegistrar + +**Decision:** The `@Volatile var iosXcodebuildProcess: Process?` and related functions (`startAndPollServer`, `buildXcodebuildCommand`, `shellQuote`) move into `IOSAutomationToolRegistrar` as private members. + +**Rationale:** This state is only used by iOS automation tools (start/stop server). Keeping it co-located with the tools that use it maintains cohesion. + +## Risks / Trade-offs + +**[Risk] DSL hides control flow** → The `tool()` function absorbs try/catch and timeout, making it less obvious what happens on error. Mitigation: `ToolScope` is small (~40 lines) and well-documented. Developers read it once and understand all tools. + +**[Risk] Visibility changes for discovery functions** → Functions currently `private` on `ToolFactory` become `internal` on `ToolDiscovery` (for testability). Mitigation: They were already `internal` in several cases; the `discovery` package boundary provides logical encapsulation. + +**[Risk] Large diff size** → Touching 1975 lines + creating 8 new files + updating 2 test files is a big PR. Mitigation: Tasks are ordered so each can be a separate commit. The refactor is mechanical — no logic changes, just moving code and removing boilerplate. + +**[Trade-off] ToolFactory constructor unchanged but internals completely different** → Anyone who was directly calling `internal` functions on `ToolFactory` (only tests) needs to update imports. Mitigation: Only 2 test files are affected, and the migration is straightforward. diff --git a/openspec/changes/refactor-toolfactory/proposal.md b/openspec/changes/refactor-toolfactory/proposal.md new file mode 100644 index 0000000..b0f123d --- /dev/null +++ b/openspec/changes/refactor-toolfactory/proposal.md @@ -0,0 +1,31 @@ +## Why + +`ToolFactory.kt` is 1975 lines — the largest file in the codebase. It tangles three distinct concerns: tool registration (36+ tools with identical boilerplate), path/asset discovery, and string-parsing helpers. Every new tool added grows this monolith, and testing individual tool groups requires instantiating the entire factory with stubs for both platforms. Splitting it now keeps complexity manageable before more tools are added. + +## What Changes + +- Introduce a `ToolScope` DSL class that absorbs the repeated try/runWithTimeout/catch/handleToolError pattern from every tool registration (~15 lines of boilerplate × 36 tools) +- Introduce a `ToolRegistrar` interface; split tool registrations into 4 platform-specific implementors (AndroidDevice, AndroidAutomation, IOSDevice, IOSAutomation) +- Add `CallToolRequest` extension helpers (`.requireString()`, `.requireInt()`, `.optionalString()`) to eliminate repeated `request.arguments["x"]?.jsonPrimitive?.content` patterns +- Move discovery functions (APK, Xcode project, xctestrun, project root, install dir) to a new `discovery/` package as a `ToolDiscovery` class +- Move pure helper functions (`extractProperty`, `extractPattern`, `formatAppInfo`) to a `tools/ToolHelpers` object +- Reduce `ToolFactory.kt` to a thin coordinator (~30 lines) that wires registrars together +- Migrate existing tests (`ToolFactoryHelpersTest`, `ToolFactoryPathTest`) to test the extracted classes directly + +## Capabilities + +### New Capabilities +- `tool-registration-dsl`: ToolScope DSL and ToolRegistrar interface that standardize how MCP tools are registered, including timeout handling, error wrapping, and parameter extraction +- `tool-discovery`: Standalone discovery logic for locating APKs, Xcode projects, xctestrun bundles, install directories, and project roots + +### Modified Capabilities + +_(none — this is a pure refactor with no behavior changes to any MCP tool or discovery logic)_ + +## Impact + +- **Code**: `app/src/main/kotlin/com/example/visiontest/ToolFactory.kt` replaced by ~8 new files across `tools/` and `discovery/` packages +- **Tests**: `ToolFactoryHelpersTest.kt` and `ToolFactoryPathTest.kt` updated with new imports; test classes simplified (no more stub/mock DeviceConfig needed for helpers and discovery) +- **APIs**: Zero change — all MCP tool names, descriptions, schemas, and behavior remain identical +- **Dependencies**: No new library dependencies +- **Entry point**: `Main.kt` unchanged — `ToolFactory(android, ios, logger).registerAllTools(server)` still works diff --git a/openspec/changes/refactor-toolfactory/specs/tool-discovery/spec.md b/openspec/changes/refactor-toolfactory/specs/tool-discovery/spec.md new file mode 100644 index 0000000..62c021c --- /dev/null +++ b/openspec/changes/refactor-toolfactory/specs/tool-discovery/spec.md @@ -0,0 +1,94 @@ +## ADDED Requirements + +### Requirement: ToolDiscovery class encapsulates all path resolution +A `ToolDiscovery` class in the `discovery/` package SHALL encapsulate all asset discovery logic previously embedded in `ToolFactory`. It SHALL accept only a `Logger` as a constructor parameter. + +#### Scenario: ToolDiscovery is independently constructable +- **WHEN** `ToolDiscovery(logger)` is constructed +- **THEN** it SHALL be ready to use without requiring `DeviceConfig`, `AutomationClient`, or any other `ToolFactory` dependency + +### Requirement: Android APK discovery +`ToolDiscovery` SHALL provide `findAutomationServerApk()` and its testable overload `findAutomationServerApk(envApkPath, searchRoots, installDir)` with identical behavior to the current `ToolFactory` implementation. + +#### Scenario: APK found via environment variable +- **WHEN** `VISION_TEST_APK_PATH` environment variable points to an existing file +- **THEN** `findAutomationServerApk()` SHALL return that file's absolute path + +#### Scenario: APK found via search roots +- **WHEN** no environment variable is set and the APK exists at `/automation-server/build/outputs/apk/androidTest/debug/automation-server-debug-androidTest.apk` +- **THEN** `findAutomationServerApk()` SHALL return the first match found across search roots + +#### Scenario: APK found in install directory +- **WHEN** no environment variable is set and no search root contains the APK, but `/automation-server-test.apk` exists +- **THEN** `findAutomationServerApk()` SHALL return the install directory APK path + +#### Scenario: No APK found +- **WHEN** no APK is found in any location +- **THEN** `findAutomationServerApk()` SHALL return null + +### Requirement: Main APK resolution from test APK path +`ToolDiscovery` SHALL provide `resolveMainApkPath(testApkPath)` with identical behavior to the current `ToolFactory` implementation. + +#### Scenario: Gradle layout derivation +- **WHEN** test APK path contains `androidTest/` and `-androidTest` substrings and the derived main APK exists +- **THEN** `resolveMainApkPath()` SHALL return the derived path + +#### Scenario: Install directory sibling lookup +- **WHEN** the test APK is named `automation-server-test.apk` and `automation-server.apk` exists in the same directory +- **THEN** `resolveMainApkPath()` SHALL return the sibling APK path + +#### Scenario: No main APK found +- **WHEN** no derivation or sibling lookup succeeds +- **THEN** `resolveMainApkPath()` SHALL return null + +### Requirement: Xcode project discovery +`ToolDiscovery` SHALL provide `findXcodeProject()` with identical cascading search behavior: environment variable → CWD → project root → code source root. + +#### Scenario: Xcode project from environment variable +- **WHEN** `VISION_TEST_IOS_PROJECT_PATH` environment variable points to a valid `.xcodeproj` directory +- **THEN** `findXcodeProject()` SHALL return its absolute path + +#### Scenario: Xcode project from project root +- **WHEN** no environment variable is set and the `.xcodeproj` exists relative to the detected project root +- **THEN** `findXcodeProject()` SHALL return its absolute path + +#### Scenario: No Xcode project found +- **WHEN** no `.xcodeproj` is found in any location +- **THEN** `findXcodeProject()` SHALL return null + +### Requirement: xctestrun bundle discovery +`ToolDiscovery` SHALL provide `findXctestrun()` and its testable overload `findXctestrun(installDir)` with identical behavior. + +#### Scenario: xctestrun found in install directory +- **WHEN** `/ios-automation-server/` contains `.xctestrun` files +- **THEN** `findXctestrun()` SHALL return the absolute path of the first file alphabetically + +#### Scenario: No xctestrun found +- **WHEN** the bundle directory does not exist or contains no `.xctestrun` files +- **THEN** `findXctestrun()` SHALL return null + +### Requirement: Project root discovery +`ToolDiscovery` SHALL provide `findProjectRoot(startFrom)` that walks up the directory tree (max 10 levels) looking for `settings.gradle.kts` or `settings.gradle`. + +#### Scenario: Project root found +- **WHEN** a `settings.gradle.kts` or `settings.gradle` file exists within 10 parent directories of `startFrom` +- **THEN** `findProjectRoot()` SHALL return the directory containing it + +#### Scenario: Project root not found within depth limit +- **WHEN** no settings file exists within 10 levels up +- **THEN** `findProjectRoot()` SHALL return null + +#### Scenario: Trailing dot in path handled +- **WHEN** `startFrom` path ends with `.` +- **THEN** `findProjectRoot()` SHALL resolve the parent correctly and search normally + +### Requirement: Install directory resolution +`ToolDiscovery` SHALL provide `resolveInstallDir()` with the cascading resolution: `VISIONTEST_DIR` env var → JAR directory → `~/.local/share/visiontest` default. + +#### Scenario: Install dir from environment variable +- **WHEN** `VISIONTEST_DIR` environment variable is set and non-empty +- **THEN** `resolveInstallDir()` SHALL return a `File` pointing to that path + +#### Scenario: Install dir default fallback +- **WHEN** no environment variable is set and not running from a JAR +- **THEN** `resolveInstallDir()` SHALL return `~/.local/share/visiontest` diff --git a/openspec/changes/refactor-toolfactory/specs/tool-registration-dsl/spec.md b/openspec/changes/refactor-toolfactory/specs/tool-registration-dsl/spec.md new file mode 100644 index 0000000..1534197 --- /dev/null +++ b/openspec/changes/refactor-toolfactory/specs/tool-registration-dsl/spec.md @@ -0,0 +1,103 @@ +## ADDED Requirements + +### Requirement: ToolScope absorbs tool registration boilerplate +The `ToolScope` class SHALL wrap `Server.addTool()` with automatic timeout enforcement, error handling via `ErrorHandler.handleToolError()`, and `CallToolResult` wrapping. Tool handlers SHALL only provide the business logic as a `suspend (CallToolRequest?) -> String` lambda. + +#### Scenario: Tool registered via ToolScope executes within timeout +- **WHEN** a tool is registered via `ToolScope.tool()` with a 10s timeout and the handler returns in 5s +- **THEN** the tool SHALL return a `CallToolResult` containing a single `TextContent` with the handler's return value + +#### Scenario: Tool registered via ToolScope times out +- **WHEN** a tool is registered via `ToolScope.tool()` with a 10s timeout and the handler takes longer than 10s +- **THEN** the tool SHALL return an error `CallToolResult` produced by `ErrorHandler.handleToolError()` with a `TimeoutCancellationException` + +#### Scenario: Tool registered via ToolScope handles exceptions +- **WHEN** a tool handler throws any `Exception` +- **THEN** the tool SHALL return an error `CallToolResult` produced by `ErrorHandler.handleToolError()` with the thrown exception and the tool name as context + +#### Scenario: Tool registered with custom timeout +- **WHEN** a tool is registered with `timeoutMs = 30000` +- **THEN** the tool SHALL use 30s as its timeout instead of the default + +### Requirement: ToolRegistrar interface for modular registration +Each platform tool group SHALL implement the `ToolRegistrar` interface with a single `registerTools(scope: ToolScope)` method. `ToolFactory.registerAllTools()` SHALL iterate over all registrars and delegate to each. + +#### Scenario: All tools registered via registrars +- **WHEN** `ToolFactory.registerAllTools(server)` is called +- **THEN** all 36 MCP tools SHALL be registered on the server with identical names, descriptions, and input schemas as the current monolithic implementation + +#### Scenario: Registrar receives ToolScope +- **WHEN** a `ToolRegistrar.registerTools(scope)` is called +- **THEN** the scope SHALL provide the server, logger, and default timeout configured in `ToolFactory` + +### Requirement: CallToolRequest parameter extraction helpers +Extension functions on `CallToolRequest?` SHALL provide type-safe parameter extraction: `requireString(key)`, `requireInt(key)`, `optionalString(key)`, `optionalInt(key)`. + +#### Scenario: requireString returns value when present +- **WHEN** `request.requireString("packageName")` is called and `packageName` exists in the request arguments +- **THEN** it SHALL return the string value + +#### Scenario: requireString throws when missing +- **WHEN** `request.requireString("packageName")` is called and `packageName` is not in the request arguments +- **THEN** it SHALL throw `IllegalArgumentException` with a message containing the key name + +#### Scenario: requireInt parses integer from string +- **WHEN** `request.requireInt("x")` is called and the argument value is `"100"` +- **THEN** it SHALL return `100` as an `Int` + +#### Scenario: requireInt throws on non-integer +- **WHEN** `request.requireInt("x")` is called and the argument value is `"abc"` +- **THEN** it SHALL throw `IllegalArgumentException` with a message indicating the key must be an integer + +#### Scenario: optionalString returns null when missing +- **WHEN** `request.optionalString("text")` is called and `text` is not in the request arguments +- **THEN** it SHALL return `null` + +### Requirement: ToolHelpers object for pure utility functions +The functions `extractProperty`, `extractPattern`, and `formatAppInfo` SHALL be moved to a `ToolHelpers` object in the `tools/` package with identical behavior. + +#### Scenario: extractProperty finds property value +- **WHEN** `ToolHelpers.extractProperty("[ro.product.model]: [Pixel 6]", "ro.product.model")` is called +- **THEN** it SHALL return `"Pixel 6"` + +#### Scenario: extractProperty returns Unknown for missing property +- **WHEN** `ToolHelpers.extractProperty("", "ro.product.model")` is called +- **THEN** it SHALL return `"Unknown"` + +#### Scenario: formatAppInfo extracts and formats app information +- **WHEN** `ToolHelpers.formatAppInfo(rawDumpsysOutput, "com.example.app")` is called with valid dumpsys output +- **THEN** it SHALL return a formatted string containing version name, version code, SDK targets, install dates, and up to 10 permissions + +### Requirement: Four platform-specific registrars +The system SHALL provide exactly four `ToolRegistrar` implementations: +- `AndroidDeviceToolRegistrar` — registers 4 Android device management tools +- `AndroidAutomationToolRegistrar` — registers 14 Android UI automation tools +- `IOSDeviceToolRegistrar` — registers 4 iOS device management tools +- `IOSAutomationToolRegistrar` — registers 10 iOS UI automation tools plus server lifecycle management + +#### Scenario: Android device tools registered +- **WHEN** `AndroidDeviceToolRegistrar.registerTools(scope)` is called +- **THEN** tools `available_device_android`, `list_apps_android`, `info_app_android`, `launch_app_android` SHALL be registered + +#### Scenario: Android automation tools registered +- **WHEN** `AndroidAutomationToolRegistrar.registerTools(scope)` is called +- **THEN** all 14 Android automation tools SHALL be registered including `install_automation_server`, `start_automation_server`, `get_ui_hierarchy`, `find_element`, `android_tap_by_coordinates`, `android_swipe`, `android_swipe_direction`, `android_swipe_on_element`, `android_press_back`, `android_press_home`, `android_input_text`, `android_get_device_info`, `get_interactive_elements`, and `automation_server_status` + +#### Scenario: iOS device tools registered +- **WHEN** `IOSDeviceToolRegistrar.registerTools(scope)` is called +- **THEN** tools `ios_available_device`, `ios_list_apps`, `ios_info_app`, `ios_launch_app` SHALL be registered + +#### Scenario: iOS automation tools registered +- **WHEN** `IOSAutomationToolRegistrar.registerTools(scope)` is called +- **THEN** all 10 iOS automation tools SHALL be registered including `ios_start_automation_server`, `ios_automation_server_status`, `ios_get_ui_hierarchy`, `ios_get_interactive_elements`, `ios_tap_by_coordinates`, `ios_swipe`, `ios_swipe_direction`, `ios_find_element`, `ios_get_device_info`, `ios_press_home`, `ios_input_text`, and `ios_stop_automation_server` + +### Requirement: ToolFactory remains the public entry point +`ToolFactory` SHALL maintain its existing constructor signature and `registerAllTools(server: Server)` method. `Main.kt` SHALL require zero changes. + +#### Scenario: ToolFactory constructor compatibility +- **WHEN** `ToolFactory(android, ios, logger)` is constructed (using defaults for optional params) +- **THEN** it SHALL compile and function identically to the pre-refactor version + +#### Scenario: registerAllTools delegates to registrars +- **WHEN** `toolFactory.registerAllTools(server)` is called +- **THEN** it SHALL create a `ToolScope` and pass it to each of the four registrars diff --git a/openspec/changes/refactor-toolfactory/tasks.md b/openspec/changes/refactor-toolfactory/tasks.md new file mode 100644 index 0000000..963d24f --- /dev/null +++ b/openspec/changes/refactor-toolfactory/tasks.md @@ -0,0 +1,37 @@ +## 1. Foundation — DSL and Interface + +- [x] 1.1 Create `app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt` with `ToolScope` class: wraps `Server.addTool()` with `runBlocking`/`withTimeout`/try-catch/`ErrorHandler.handleToolError()`/`CallToolResult` wrapping. Accepts `server`, `logger`, `defaultTimeoutMs` in constructor. Provides `tool(name, description, inputSchema, timeoutMs, handler)` function. +- [x] 1.2 Add `CallToolRequest` extension helpers in `ToolDsl.kt`: `requireString(key)`, `requireInt(key)`, `optionalString(key)`, `optionalInt(key)`. `require*` throws `IllegalArgumentException` on missing/invalid values. `optional*` returns null. +- [x] 1.3 Create `app/src/main/kotlin/com/example/visiontest/tools/ToolRegistrar.kt` with `interface ToolRegistrar { fun registerTools(scope: ToolScope) }`. + +## 2. Extract Helpers + +- [ ] 2.1 Create `app/src/main/kotlin/com/example/visiontest/tools/ToolHelpers.kt` with `object ToolHelpers` containing `extractProperty()`, `extractPattern()`, `formatAppInfo()` — exact same logic as current `ToolFactory`. +- [ ] 2.2 Update `app/src/test/kotlin/com/example/visiontest/ToolFactoryHelpersTest.kt`: change to test `ToolHelpers` directly, remove the stub `DeviceConfig` and `ToolFactory` instantiation. + +## 3. Extract Discovery + +- [ ] 3.1 Create `app/src/main/kotlin/com/example/visiontest/discovery/ToolDiscovery.kt` with `class ToolDiscovery(private val logger: Logger)`. Move functions: `findAutomationServerApk()` (both overloads), `resolveMainApkPath()`, `findXcodeProject()`, `isValidXcodeProjectPath()`, `findXctestrun()` (both overloads), `findProjectRoot()`, `findCodeSourceRoot()`, `resolveInstallDir()`, `findJarDirectory()`. +- [ ] 3.2 Update `app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt`: change to test `ToolDiscovery` directly, remove mock `DeviceConfig` and `ToolFactory` instantiation. Keep all existing test cases. + +## 4. Android Registrars + +- [ ] 4.1 Create `app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`. Register 4 tools: `available_device_android`, `list_apps_android`, `info_app_android`, `launch_app_android`. Use `ToolScope.tool()` DSL and `ToolHelpers` for property extraction/formatting. +- [ ] 4.2 Create `app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`, `AutomationClient`, `ToolDiscovery`. Register 14 tools: `install_automation_server`, `start_automation_server`, `automation_server_status`, `get_ui_hierarchy`, `find_element`, `android_tap_by_coordinates`, `android_swipe`, `android_swipe_direction`, `android_swipe_on_element`, `android_press_back`, `android_press_home`, `android_input_text`, `android_get_device_info`, `get_interactive_elements`. Use `ToolScope.tool()` DSL and request extension helpers. + +## 5. iOS Registrars + +- [ ] 5.1 Create `app/src/main/kotlin/com/example/visiontest/tools/IOSDeviceToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`. Register 4 tools: `ios_available_device`, `ios_list_apps`, `ios_info_app`, `ios_launch_app`. Use `ToolScope.tool()` DSL. +- [ ] 5.2 Create `app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`, `IOSAutomationClient`, `ToolDiscovery`. Move `@Volatile iosXcodebuildProcess`, `buildXcodebuildCommand()`, `shellQuote()`, `startAndPollServer()`, `ServerPollResult` into this class. Register 12 tools: `ios_start_automation_server`, `ios_automation_server_status`, `ios_get_ui_hierarchy`, `ios_get_interactive_elements`, `ios_tap_by_coordinates`, `ios_swipe`, `ios_swipe_direction`, `ios_find_element`, `ios_get_device_info`, `ios_press_home`, `ios_input_text`, `ios_stop_automation_server`. Use `ToolScope.tool()` DSL. + +## 6. Wire Up and Replace + +- [ ] 6.1 Replace `ToolFactory.kt` content with thin coordinator: constructor stays the same, creates `ToolDiscovery` and 4 registrars, `registerAllTools()` creates `ToolScope` and delegates. Remove all tool registration methods, helpers, and discovery functions. +- [ ] 6.2 Verify `Main.kt` requires zero changes — `ToolFactory(android, ios, logger).registerAllTools(server)` still compiles. + +## 7. Verify and Document + +- [ ] 7.1 Run `./gradlew test` — all existing tests must pass with updated imports. +- [ ] 7.2 Run `./gradlew build` — full build must succeed with no warnings related to the refactor. +- [ ] 7.3 Update `CLAUDE.md` Architecture Overview section to reflect the new `tools/` and `discovery/` packages and file structure. +- [ ] 7.4 Update `CLAUDE.md` Unit Tests section to reflect the renamed/relocated test files. From c098fa5f6c5c2524bc24c7bdecbd51c0d592e9e0 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 12:24:52 +0100 Subject: [PATCH 02/10] refactor: extract ToolHelpers object from ToolFactory --- .../example/visiontest/tools/ToolHelpers.kt | 46 +++++++++++++++++++ .../visiontest/ToolFactoryHelpersTest.kt | 44 +++++------------- .../changes/refactor-toolfactory/tasks.md | 4 +- 3 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 app/src/main/kotlin/com/example/visiontest/tools/ToolHelpers.kt diff --git a/app/src/main/kotlin/com/example/visiontest/tools/ToolHelpers.kt b/app/src/main/kotlin/com/example/visiontest/tools/ToolHelpers.kt new file mode 100644 index 0000000..d173822 --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/tools/ToolHelpers.kt @@ -0,0 +1,46 @@ +package com.example.visiontest.tools + +object ToolHelpers { + + fun extractProperty(propOutput: String, propName: String): String { + val regex = Regex("\\[$propName]: \\[(.+?)]") + return regex.find(propOutput)?.groupValues?.get(1) ?: "Unknown" + } + + fun formatAppInfo(rawInfo: String, packageName: String): String { + // Extract useful information using regex patterns + val versionName = extractPattern(rawInfo, "versionName=(\\S+)") + val versionCode = extractPattern(rawInfo, "versionCode=(\\d+)") + val firstInstallTime = extractPattern(rawInfo, "firstInstallTime=(\\S+)") + val lastUpdateTime = extractPattern(rawInfo, "lastUpdateTime=(\\S+)") + val targetSdk = extractPattern(rawInfo, "targetSdk=(\\d+)") + val minSdk = extractPattern(rawInfo, "minSdk=(\\d+)") + + // Extract permissions + val permissions = Regex("grantedPermissions:(.*?)(?=\\n\\n)", RegexOption.DOT_MATCHES_ALL) + .find(rawInfo)?.groupValues?.get(1) + ?.split("\n") + ?.filter { it.contains("permission.") } + ?.map { it.trim() } + ?.take(10) // Limit to first 10 permissions + ?.joinToString("\n - ") ?: "None" + + return """ + |App Information for $packageName: + |------------------------- + |Version: $versionName (Code: $versionCode) + |SDK: Target=$targetSdk, Minimum=$minSdk + |Installation: + | - First Installed: $firstInstallTime + | - Last Updated: $lastUpdateTime + | + |Key Permissions (first 10): + | - $permissions + |${if (permissions != "None") "\n[Additional permissions omitted for brevity]" else ""} + """.trimMargin() + } + + fun extractPattern(text: String, pattern: String): String { + return Regex(pattern).find(text)?.groupValues?.get(1) ?: "Unknown" + } +} diff --git a/app/src/test/kotlin/com/example/visiontest/ToolFactoryHelpersTest.kt b/app/src/test/kotlin/com/example/visiontest/ToolFactoryHelpersTest.kt index 5f70161..4e21017 100644 --- a/app/src/test/kotlin/com/example/visiontest/ToolFactoryHelpersTest.kt +++ b/app/src/test/kotlin/com/example/visiontest/ToolFactoryHelpersTest.kt @@ -1,51 +1,29 @@ package com.example.visiontest -import com.example.visiontest.android.AutomationClient -import com.example.visiontest.common.DeviceConfig -import com.example.visiontest.common.MobileDevice -import com.example.visiontest.ios.IOSAutomationClient -import org.slf4j.LoggerFactory +import com.example.visiontest.tools.ToolHelpers import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class ToolFactoryHelpersTest { - // Minimal DeviceConfig stub - methods won't be called in helper tests - private val stubDevice = object : DeviceConfig { - override suspend fun listDevices(): List = emptyList() - override suspend fun getFirstAvailableDevice(): MobileDevice = throw NotImplementedError() - override suspend fun listApps(deviceId: String?): List = emptyList() - override suspend fun getAppInfo(packageName: String, deviceId: String?): String = "" - override suspend fun launchApp(packageName: String, activityName: String?, deviceId: String?): Boolean = false - override suspend fun executeShell(command: String, deviceId: String?): String = "" - } - - private val factory = ToolFactory( - android = stubDevice, - ios = stubDevice, - logger = LoggerFactory.getLogger(ToolFactoryHelpersTest::class.java), - automationClient = AutomationClient(), - iosAutomationClient = IOSAutomationClient() - ) - // --- extractProperty --- @Test fun `extractProperty finds property value`() { val output = "[ro.product.model]: [Pixel 6]\n[ro.build.version.release]: [14]" - assertEquals("Pixel 6", factory.extractProperty(output, "ro.product.model")) + assertEquals("Pixel 6", ToolHelpers.extractProperty(output, "ro.product.model")) } @Test fun `extractProperty returns Unknown when property missing`() { val output = "[ro.product.model]: [Pixel 6]" - assertEquals("Unknown", factory.extractProperty(output, "ro.build.version.sdk")) + assertEquals("Unknown", ToolHelpers.extractProperty(output, "ro.build.version.sdk")) } @Test fun `extractProperty handles empty output`() { - assertEquals("Unknown", factory.extractProperty("", "ro.product.model")) + assertEquals("Unknown", ToolHelpers.extractProperty("", "ro.product.model")) } @Test @@ -55,20 +33,20 @@ class ToolFactoryHelpersTest { [ro.build.version.release]: [14] [ro.build.version.sdk]: [34] """.trimIndent() - assertEquals("14", factory.extractProperty(output, "ro.build.version.release")) - assertEquals("34", factory.extractProperty(output, "ro.build.version.sdk")) + assertEquals("14", ToolHelpers.extractProperty(output, "ro.build.version.release")) + assertEquals("34", ToolHelpers.extractProperty(output, "ro.build.version.sdk")) } // --- extractPattern --- @Test fun `extractPattern returns matched group`() { - assertEquals("1.0.0", factory.extractPattern("versionName=1.0.0", "versionName=(\\S+)")) + assertEquals("1.0.0", ToolHelpers.extractPattern("versionName=1.0.0", "versionName=(\\S+)")) } @Test fun `extractPattern returns Unknown on no match`() { - assertEquals("Unknown", factory.extractPattern("no match here", "versionName=(\\S+)")) + assertEquals("Unknown", ToolHelpers.extractPattern("no match here", "versionName=(\\S+)")) } // --- formatAppInfo --- @@ -84,7 +62,7 @@ class ToolFactoryHelpersTest { lastUpdateTime=2024-06-20 """.trimIndent() - val result = factory.formatAppInfo(rawInfo, "com.example.app") + val result = ToolHelpers.formatAppInfo(rawInfo, "com.example.app") assertTrue(result.contains("2.1.0")) assertTrue(result.contains("42")) assertTrue(result.contains("34")) @@ -96,7 +74,7 @@ class ToolFactoryHelpersTest { @Test fun `formatAppInfo returns Unknown for missing fields`() { - val result = factory.formatAppInfo("", "com.test") + val result = ToolHelpers.formatAppInfo("", "com.test") assertTrue(result.contains("Unknown")) } @@ -112,7 +90,7 @@ class ToolFactoryHelpersTest { otherSection: """.trimIndent() - val result = factory.formatAppInfo(rawInfo, "com.test") + val result = ToolHelpers.formatAppInfo(rawInfo, "com.test") // Should contain at most 10 permission entries val permCount = Regex("android\\.permission\\.PERM_").findAll(result).count() assertTrue(permCount <= 10, "Expected at most 10 permissions, got $permCount") diff --git a/openspec/changes/refactor-toolfactory/tasks.md b/openspec/changes/refactor-toolfactory/tasks.md index 963d24f..9990763 100644 --- a/openspec/changes/refactor-toolfactory/tasks.md +++ b/openspec/changes/refactor-toolfactory/tasks.md @@ -6,8 +6,8 @@ ## 2. Extract Helpers -- [ ] 2.1 Create `app/src/main/kotlin/com/example/visiontest/tools/ToolHelpers.kt` with `object ToolHelpers` containing `extractProperty()`, `extractPattern()`, `formatAppInfo()` — exact same logic as current `ToolFactory`. -- [ ] 2.2 Update `app/src/test/kotlin/com/example/visiontest/ToolFactoryHelpersTest.kt`: change to test `ToolHelpers` directly, remove the stub `DeviceConfig` and `ToolFactory` instantiation. +- [x] 2.1 Create `app/src/main/kotlin/com/example/visiontest/tools/ToolHelpers.kt` with `object ToolHelpers` containing `extractProperty()`, `extractPattern()`, `formatAppInfo()` — exact same logic as current `ToolFactory`. +- [x] 2.2 Update `app/src/test/kotlin/com/example/visiontest/ToolFactoryHelpersTest.kt`: change to test `ToolHelpers` directly, remove the stub `DeviceConfig` and `ToolFactory` instantiation. ## 3. Extract Discovery From dc44fce34ba2fd8c5ae5efc884aa765227d99c19 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 12:33:34 +0100 Subject: [PATCH 03/10] feat: extract ToolDiscovery class from ToolFactory --- .../visiontest/discovery/ToolDiscovery.kt | 264 ++++++++++++++++++ .../example/visiontest/ToolFactoryPathTest.kt | 72 ++--- .../changes/refactor-toolfactory/tasks.md | 4 +- 3 files changed, 304 insertions(+), 36 deletions(-) create mode 100644 app/src/main/kotlin/com/example/visiontest/discovery/ToolDiscovery.kt diff --git a/app/src/main/kotlin/com/example/visiontest/discovery/ToolDiscovery.kt b/app/src/main/kotlin/com/example/visiontest/discovery/ToolDiscovery.kt new file mode 100644 index 0000000..4ecec3c --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/discovery/ToolDiscovery.kt @@ -0,0 +1,264 @@ +package com.example.visiontest.discovery + +import com.example.visiontest.config.IOSAutomationConfig +import org.slf4j.Logger +import java.io.File + +class ToolDiscovery(private val logger: Logger) { + + // ==================== Xcode Project Discovery ==================== + + internal fun isValidXcodeProjectPath(file: File): Boolean { + return file.exists() && file.isDirectory && file.name.endsWith(".xcodeproj") + } + + fun findXcodeProject(): String? { + // 0. Check environment variable first (allows explicit override) + System.getenv(IOSAutomationConfig.XCODE_PROJECT_PATH_ENV)?.let { envPath -> + val envFile = File(envPath) + if (isValidXcodeProjectPath(envFile)) { + logger.info("Using Xcode project from ${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV}: $envPath") + return envFile.absolutePath + } + if (envFile.exists()) { + logger.warn("${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV} path is not a valid .xcodeproj directory: $envPath") + } else { + logger.warn("${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV} set but path not found: $envPath") + } + } + + val relativePath = IOSAutomationConfig.XCODE_PROJECT_PATH + + // 1. Try relative to current working directory + val cwdFile = File(relativePath) + if (isValidXcodeProjectPath(cwdFile)) { + return cwdFile.absolutePath + } + + // 2. Try relative to project root + val projectRoot = findProjectRoot(File(".").absoluteFile) + if (projectRoot != null) { + val projectFile = File(projectRoot, relativePath) + if (isValidXcodeProjectPath(projectFile)) { + return projectFile.absolutePath + } + } + + // 3. Try relative to code source + val codeSourceRoot = findCodeSourceRoot() + if (codeSourceRoot != null) { + val codeSourceFile = File(codeSourceRoot, relativePath) + if (isValidXcodeProjectPath(codeSourceFile)) { + return codeSourceFile.absolutePath + } + } + + return null + } + + // ==================== Install Directory Resolution ==================== + + /** + * Resolves the install directory from VISIONTEST_DIR env var, JAR directory, or default. + * Shared by APK discovery and iOS xctestrun discovery. + */ + internal fun resolveInstallDir(): File { + val installDirPath = System.getenv("VISIONTEST_DIR") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: findJarDirectory()?.absolutePath + ?: "${System.getProperty("user.home")}/.local/share/visiontest" + return File(installDirPath) + } + + // ==================== Android APK Discovery ==================== + + fun findAutomationServerApk(): String? { + val cwd = File(".").absoluteFile + val codeSourceRoot = findCodeSourceRoot() + return findAutomationServerApk( + envApkPath = System.getenv("VISION_TEST_APK_PATH"), + searchRoots = listOfNotNull(cwd, codeSourceRoot, findProjectRoot(cwd)), + installDir = resolveInstallDir() + ) + } + + /** + * Given the path to a test APK, resolves the corresponding main APK path. + * + * For Gradle build output (androidTest path), derives the main APK by stripping "androidTest/" and "-androidTest". + * For install-dir APKs (e.g. automation-server-test.apk), looks for a sibling automation-server.apk. + * Returns null if no main APK can be found. + */ + internal fun resolveMainApkPath(testApkPath: String): String? { + // Derive main APK path from Gradle androidTest layout + val derivedPath = testApkPath + .replaceFirst("androidTest/", "") + .replaceFirst("-androidTest", "") + val derivedFile = File(derivedPath) + val isSamePath = derivedPath == testApkPath + val isKnownTestName = testApkPath.endsWith("automation-server-test.apk") + + if (derivedFile.exists() && !isSamePath && !isKnownTestName) { + return derivedPath + } + + // Fallback: check for simple-named APK in the same directory (install dir) + val parent = File(testApkPath).parentFile ?: return null + val siblingApk = File(parent, "automation-server.apk") + return if (siblingApk.exists()) siblingApk.absolutePath else null + } + + /** + * Returns the directory containing the running JAR, or null if not running from a JAR. + * Used to discover APKs co-located with the JAR in custom install directories. + * + * Uses this class's code source — works because ToolDiscovery is in the same JAR as ToolFactory. + */ + private fun findJarDirectory(): File? { + return try { + val location = this::class.java.protectionDomain?.codeSource?.location?.toURI()?.let { File(it) } + if (location != null && location.isFile && location.name.endsWith(".jar")) { + location.parentFile + } else null + } catch (e: Exception) { + logger.debug("Could not determine JAR directory: ${e.message}") + null + } + } + + internal fun findAutomationServerApk( + envApkPath: String?, + searchRoots: List, + installDir: File? = null + ): String? { + val apkRelativePath = "automation-server/build/outputs/apk/androidTest/debug/automation-server-debug-androidTest.apk" + + logger.debug("Searching for automation server APK...") + + // 1. Check environment variable first (allows explicit configuration) + envApkPath?.let { envPath -> + val file = File(envPath) + if (file.exists()) { + logger.info("Using APK from VISION_TEST_APK_PATH: $envPath") + return file.absolutePath + } + logger.warn("VISION_TEST_APK_PATH set but file not found: $envPath") + } + + // 2. Try relative to each search root (CWD, code source, project root) + for (root in searchRoots) { + val apkFile = File(root, apkRelativePath) + logger.debug("Checking path: ${apkFile.absolutePath}") + if (apkFile.exists()) { + logger.info("Found APK at: ${apkFile.absolutePath}") + return apkFile.absolutePath + } + } + + // 3. Try install directory as lowest-priority fallback + if (installDir != null) { + val installedApk = File(installDir, "automation-server-test.apk") + if (installedApk.exists()) { + logger.info("Found APK in install directory: ${installedApk.absolutePath}") + return installedApk.absolutePath + } + } + + logger.warn("APK not found in ${searchRoots.size} search roots.") + logger.warn("Re-run install.sh to download APKs, or set VISION_TEST_APK_PATH environment variable.") + return null + } + + // ==================== iOS xctestrun Discovery ==================== + + fun findXctestrun(): String? { + return findXctestrun(resolveInstallDir()) + } + + /** + * Searches for a pre-built .xctestrun file in the install directory's + * ios-automation-server/ subdirectory. + * + * Returns the absolute path to the first .xctestrun file found (sorted alphabetically), + * or null if none exists. + */ + internal fun findXctestrun(installDir: File): String? { + val bundleDir = File(installDir, IOSAutomationConfig.XCTESTRUN_BUNDLE_DIR) + logger.debug("Searching for .xctestrun in: ${bundleDir.absolutePath}") + + if (!bundleDir.isDirectory) { + logger.debug("iOS bundle directory does not exist: ${bundleDir.absolutePath}") + return null + } + + val xctestrunFiles = bundleDir.listFiles { file -> + file.isFile && file.name.endsWith(".xctestrun") + }?.sortedBy { it.name } + + if (xctestrunFiles.isNullOrEmpty()) { + logger.debug("No .xctestrun files found in: ${bundleDir.absolutePath}") + return null + } + + if (xctestrunFiles.size > 1) { + logger.info("Multiple .xctestrun files found, using first: ${xctestrunFiles.first().name}") + } + + val result = xctestrunFiles.first().absolutePath + logger.info("Found xctestrun: $result") + return result + } + + // ==================== Project Root Discovery ==================== + + // Uses this class's code source — works because ToolDiscovery is in the same JAR as ToolFactory. + internal fun findCodeSourceRoot(): File? { + return try { + val codeSource = this::class.java.protectionDomain?.codeSource + val location = codeSource?.location?.toURI()?.let { File(it) } + + if (location != null) { + logger.debug("Code source location: ${location.absolutePath}") + + // If running from JAR (app/build/libs/visiontest.jar), go up 3 levels to project root + if (location.isFile && location.name.endsWith(".jar")) { + return location.parentFile?.parentFile?.parentFile + } + + // If running from classes dir (app/build/classes/kotlin/main), go up 5 levels + if (location.isDirectory && location.path.contains("build/classes")) { + return location.parentFile?.parentFile?.parentFile?.parentFile?.parentFile + } + + // Try going up until we find settings.gradle.kts + return findProjectRoot(location) + } + null + } catch (e: Exception) { + logger.debug("Could not determine code source location: ${e.message}") + null + } + } + + internal fun findProjectRoot(startFrom: File): File? { + var current = startFrom.absoluteFile + // Handle trailing "." in path + if (current.name == ".") { + current = current.parentFile ?: return null + } + + repeat(10) { + val settingsKts = File(current, "settings.gradle.kts") + val settingsGroovy = File(current, "settings.gradle") + logger.debug("Checking for settings.gradle in: ${current.absolutePath}") + + if (settingsKts.exists() || settingsGroovy.exists()) { + logger.debug("Found project root: ${current.absolutePath}") + return current + } + current = current.parentFile ?: return null + } + return null + } +} diff --git a/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt b/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt index 038e9fb..95cbfca 100644 --- a/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt +++ b/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt @@ -1,6 +1,7 @@ package com.example.visiontest import com.example.visiontest.common.DeviceConfig +import com.example.visiontest.discovery.ToolDiscovery import io.mockk.mockk import org.junit.jupiter.api.io.TempDir import org.slf4j.LoggerFactory @@ -14,9 +15,11 @@ import kotlin.test.assertTrue class ToolFactoryPathTest { private val logger = LoggerFactory.getLogger("test") + private val discovery = ToolDiscovery(logger) + + // ToolFactory still needed for buildXcodebuildCommand tests (moves in a later step) private val mockAndroid = mockk(relaxed = true) private val mockIos = mockk(relaxed = true) - private val factory = ToolFactory( android = mockAndroid, ios = mockIos, @@ -43,7 +46,7 @@ class ToolFactoryPathTest { File(tempDir, "settings.gradle.kts").createNewFile() val subDir = File(tempDir, "app/src").apply { mkdirs() } - val result = factory.findProjectRoot(subDir) + val result = discovery.findProjectRoot(subDir) assertNotNull(result) assertEquals(tempDir.absolutePath, result.absolutePath) @@ -54,7 +57,7 @@ class ToolFactoryPathTest { File(tempDir, "settings.gradle").createNewFile() val subDir = File(tempDir, "module/deep").apply { mkdirs() } - val result = factory.findProjectRoot(subDir) + val result = discovery.findProjectRoot(subDir) assertNotNull(result) assertEquals(tempDir.absolutePath, result.absolutePath) @@ -67,7 +70,7 @@ class ToolFactoryPathTest { dir = File(dir, "level$i").apply { mkdirs() } } - val result = factory.findProjectRoot(dir) + val result = discovery.findProjectRoot(dir) assertNull(result) } @@ -76,7 +79,7 @@ class ToolFactoryPathTest { fun `findProjectRoot handles startFrom being the root itself`(@TempDir tempDir: File) { File(tempDir, "settings.gradle.kts").createNewFile() - val result = factory.findProjectRoot(tempDir) + val result = discovery.findProjectRoot(tempDir) assertNotNull(result) assertEquals(tempDir.absolutePath, result.absolutePath) @@ -87,7 +90,7 @@ class ToolFactoryPathTest { File(tempDir, "settings.gradle.kts").createNewFile() val dotDir = File(tempDir, ".") - val result = factory.findProjectRoot(dotDir) + val result = discovery.findProjectRoot(dotDir) assertNotNull(result) assertEquals(tempDir.absolutePath, result.absolutePath) @@ -99,7 +102,7 @@ class ToolFactoryPathTest { File(tempDir, "settings.gradle").createNewFile() val subDir = File(tempDir, "sub").apply { mkdirs() } - val result = factory.findProjectRoot(subDir) + val result = discovery.findProjectRoot(subDir) assertNotNull(result) assertEquals(tempDir.absolutePath, result.absolutePath) @@ -110,7 +113,7 @@ class ToolFactoryPathTest { File(tempDir, "settings.gradle.kts").createNewFile() val child = File(tempDir, "app").apply { mkdirs() } - val result = factory.findProjectRoot(child) + val result = discovery.findProjectRoot(child) assertNotNull(result) assertEquals(tempDir.absolutePath, result.absolutePath) @@ -120,7 +123,7 @@ class ToolFactoryPathTest { @Test fun `findAutomationServerApk returns null when no APK and no env var`(@TempDir tempDir: File) { - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = listOf(tempDir) ) @@ -132,7 +135,7 @@ class ToolFactoryPathTest { fun `findAutomationServerApk returns env var path when file exists`(@TempDir tempDir: File) { val apkFile = File(tempDir, "custom.apk").apply { createNewFile() } - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = apkFile.absolutePath, searchRoots = emptyList() ) @@ -143,7 +146,7 @@ class ToolFactoryPathTest { @Test fun `findAutomationServerApk returns null when env var points to missing file`(@TempDir tempDir: File) { - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = File(tempDir, "nonexistent.apk").absolutePath, searchRoots = emptyList() ) @@ -155,7 +158,7 @@ class ToolFactoryPathTest { fun `findAutomationServerApk finds APK relative to search root`(@TempDir tempDir: File) { val apkFile = createApkIn(tempDir) - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = listOf(tempDir) ) @@ -171,7 +174,7 @@ class ToolFactoryPathTest { val searchRoot = File(tempDir, "project").apply { mkdirs() } createApkIn(searchRoot) - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = envApk.absolutePath, searchRoots = listOf(searchRoot) ) @@ -185,7 +188,7 @@ class ToolFactoryPathTest { val searchRoot = File(tempDir, "project").apply { mkdirs() } val apkFile = createApkIn(searchRoot) - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = File(tempDir, "missing.apk").absolutePath, searchRoots = listOf(searchRoot) ) @@ -201,7 +204,7 @@ class ToolFactoryPathTest { val firstApk = createApkIn(first) createApkIn(second) - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = listOf(first, second) ) @@ -216,7 +219,7 @@ class ToolFactoryPathTest { val validRoot = File(tempDir, "valid").apply { mkdirs() } val apkFile = createApkIn(validRoot) - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = listOf(emptyRoot, validRoot) ) @@ -227,7 +230,7 @@ class ToolFactoryPathTest { @Test fun `findAutomationServerApk returns null with empty search roots and no env var`() { - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = emptyList() ) @@ -239,7 +242,7 @@ class ToolFactoryPathTest { fun `findAutomationServerApk returned path ends with expected APK filename`(@TempDir tempDir: File) { createApkIn(tempDir) - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = listOf(tempDir) ) @@ -255,7 +258,7 @@ class ToolFactoryPathTest { val installDir = File(tempDir, "install").apply { mkdirs() } File(installDir, "automation-server-test.apk").createNewFile() - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = emptyList(), installDir = installDir @@ -271,7 +274,7 @@ class ToolFactoryPathTest { val installDir = File(tempDir, "install").apply { mkdirs() } File(installDir, "automation-server-test.apk").createNewFile() - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = envApk.absolutePath, searchRoots = emptyList(), installDir = installDir @@ -288,7 +291,7 @@ class ToolFactoryPathTest { val installDir = File(tempDir, "install").apply { mkdirs() } File(installDir, "automation-server-test.apk").createNewFile() - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = listOf(searchRoot), installDir = installDir @@ -302,7 +305,7 @@ class ToolFactoryPathTest { fun `findAutomationServerApk returns null when installDir has no APK`(@TempDir tempDir: File) { val installDir = File(tempDir, "install").apply { mkdirs() } - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = emptyList(), installDir = installDir @@ -313,7 +316,7 @@ class ToolFactoryPathTest { @Test fun `findAutomationServerApk returns null when installDir is null`() { - val result = factory.findAutomationServerApk( + val result = discovery.findAutomationServerApk( envApkPath = null, searchRoots = emptyList(), installDir = null @@ -332,7 +335,7 @@ class ToolFactoryPathTest { val testApk = File(testApkDir, "automation-server-debug-androidTest.apk").apply { createNewFile() } val mainApk = File(mainApkDir, "automation-server-debug.apk").apply { createNewFile() } - val result = factory.resolveMainApkPath(testApk.absolutePath) + val result = discovery.resolveMainApkPath(testApk.absolutePath) assertNotNull(result) assertEquals(mainApk.absolutePath, result) @@ -343,7 +346,7 @@ class ToolFactoryPathTest { val testApk = File(tempDir, "automation-server-test.apk").apply { createNewFile() } val mainApk = File(tempDir, "automation-server.apk").apply { createNewFile() } - val result = factory.resolveMainApkPath(testApk.absolutePath) + val result = discovery.resolveMainApkPath(testApk.absolutePath) assertNotNull(result) assertEquals(mainApk.absolutePath, result) @@ -353,7 +356,7 @@ class ToolFactoryPathTest { fun `resolveMainApkPath returns null when no main APK exists`(@TempDir tempDir: File) { val testApk = File(tempDir, "automation-server-test.apk").apply { createNewFile() } - val result = factory.resolveMainApkPath(testApk.absolutePath) + val result = discovery.resolveMainApkPath(testApk.absolutePath) assertNull(result) } @@ -364,7 +367,7 @@ class ToolFactoryPathTest { // so the derived path equals the input — should NOT treat test APK as main APK val testApk = File(tempDir, "automation-server-test.apk").apply { createNewFile() } - val result = factory.resolveMainApkPath(testApk.absolutePath) + val result = discovery.resolveMainApkPath(testApk.absolutePath) // Without a sibling automation-server.apk, result should be null assertNull(result) @@ -372,7 +375,7 @@ class ToolFactoryPathTest { @Test fun `resolveMainApkPath returns null for bare filename with no parent directory`() { - val result = factory.resolveMainApkPath("test.apk") + val result = discovery.resolveMainApkPath("test.apk") assertNull(result) } @@ -384,7 +387,7 @@ class ToolFactoryPathTest { val bundleDir = File(tempDir, "ios-automation-server").apply { mkdirs() } File(bundleDir, "IOSAutomationServer_iphonesimulator18.0-arm64.xctestrun").createNewFile() - val result = factory.findXctestrun(tempDir) + val result = discovery.findXctestrun(tempDir) assertNotNull(result) assertTrue(result.endsWith(".xctestrun")) @@ -394,7 +397,7 @@ class ToolFactoryPathTest { fun `findXctestrun returns null when install directory has no xctestrun`(@TempDir tempDir: File) { File(tempDir, "ios-automation-server").mkdirs() - val result = factory.findXctestrun(tempDir) + val result = discovery.findXctestrun(tempDir) assertNull(result) } @@ -403,7 +406,7 @@ class ToolFactoryPathTest { fun `findXctestrun returns null when install directory does not exist`(@TempDir tempDir: File) { val nonExistent = File(tempDir, "nonexistent") - val result = factory.findXctestrun(nonExistent) + val result = discovery.findXctestrun(nonExistent) assertNull(result) } @@ -414,7 +417,7 @@ class ToolFactoryPathTest { File(bundleDir, "B_iphonesimulator18.0.xctestrun").createNewFile() File(bundleDir, "A_iphonesimulator17.0.xctestrun").createNewFile() - val result = factory.findXctestrun(tempDir) + val result = discovery.findXctestrun(tempDir) assertNotNull(result) assertTrue(result.contains("A_iphonesimulator17.0.xctestrun")) @@ -425,7 +428,7 @@ class ToolFactoryPathTest { val bundleDir = File(tempDir, "ios-automation-server").apply { mkdirs() } File(bundleDir, "Test.xctestrun").createNewFile() - val result = factory.findXctestrun(tempDir) + val result = discovery.findXctestrun(tempDir) assertNotNull(result) assertTrue(File(result).isAbsolute) @@ -437,12 +440,13 @@ class ToolFactoryPathTest { File(bundleDir, "IOSAutomationServer.app").mkdirs() File(bundleDir, "readme.txt").createNewFile() - val result = factory.findXctestrun(tempDir) + val result = discovery.findXctestrun(tempDir) assertNull(result) } // ==================== buildXcodebuildCommand ==================== + // These tests still reference ToolFactory — will move to IOSAutomationToolRegistrar tests later @Test fun `buildXcodebuildCommand produces test-without-building for pre-built path`() { diff --git a/openspec/changes/refactor-toolfactory/tasks.md b/openspec/changes/refactor-toolfactory/tasks.md index 9990763..a5b307b 100644 --- a/openspec/changes/refactor-toolfactory/tasks.md +++ b/openspec/changes/refactor-toolfactory/tasks.md @@ -11,8 +11,8 @@ ## 3. Extract Discovery -- [ ] 3.1 Create `app/src/main/kotlin/com/example/visiontest/discovery/ToolDiscovery.kt` with `class ToolDiscovery(private val logger: Logger)`. Move functions: `findAutomationServerApk()` (both overloads), `resolveMainApkPath()`, `findXcodeProject()`, `isValidXcodeProjectPath()`, `findXctestrun()` (both overloads), `findProjectRoot()`, `findCodeSourceRoot()`, `resolveInstallDir()`, `findJarDirectory()`. -- [ ] 3.2 Update `app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt`: change to test `ToolDiscovery` directly, remove mock `DeviceConfig` and `ToolFactory` instantiation. Keep all existing test cases. +- [x] 3.1 Create `app/src/main/kotlin/com/example/visiontest/discovery/ToolDiscovery.kt` with `class ToolDiscovery(private val logger: Logger)`. Move functions: `findAutomationServerApk()` (both overloads), `resolveMainApkPath()`, `findXcodeProject()`, `isValidXcodeProjectPath()`, `findXctestrun()` (both overloads), `findProjectRoot()`, `findCodeSourceRoot()`, `resolveInstallDir()`, `findJarDirectory()`. +- [x] 3.2 Update `app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt`: change to test `ToolDiscovery` directly, remove mock `DeviceConfig` and `ToolFactory` instantiation. Keep all existing test cases. ## 4. Android Registrars From c7f53a1f8176b591ef33bec55b4b12ee1c258879 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 12:49:19 +0100 Subject: [PATCH 04/10] feat: extract Android tools into device and automation registrars --- .../tools/AndroidAutomationToolRegistrar.kt | 547 ++++++++++++++++++ .../tools/AndroidDeviceToolRegistrar.kt | 87 +++ .../changes/refactor-toolfactory/tasks.md | 4 +- 3 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt create mode 100644 app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt diff --git a/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt new file mode 100644 index 0000000..edfd0a6 --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt @@ -0,0 +1,547 @@ +package com.example.visiontest.tools + +import com.example.visiontest.android.Android +import com.example.visiontest.android.AutomationClient +import com.example.visiontest.common.DeviceConfig +import com.example.visiontest.config.AutomationConfig +import com.example.visiontest.discovery.ToolDiscovery +import io.modelcontextprotocol.kotlin.sdk.Tool +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +class AndroidAutomationToolRegistrar( + private val android: DeviceConfig, + private val automationClient: AutomationClient, + private val discovery: ToolDiscovery +) : ToolRegistrar { + + override fun registerTools(scope: ToolScope) { + registerInstallAutomationServer(scope) + registerStartAutomationServer(scope) + registerAutomationServerStatus(scope) + registerGetUiHierarchy(scope) + registerFindElement(scope) + registerTapByCoordinates(scope) + registerSwipe(scope) + registerSwipeDirection(scope) + registerSwipeOnElement(scope) + registerPressBack(scope) + registerPressHome(scope) + registerInputText(scope) + registerGetDeviceInfo(scope) + registerGetInteractiveElements(scope) + } + + private fun registerInstallAutomationServer(scope: ToolScope) { + scope.tool( + name = "install_automation_server", + description = "Installs the automation server APKs on the connected Android device. Run this once before using start_automation_server." + ) { + val device = android.getFirstAvailableDevice() + + val apkPath = discovery.findAutomationServerApk() + ?: return@tool "Automation server APK not found. Re-run install.sh to download APKs, or set VISION_TEST_APK_PATH environment variable. To build from source: ./gradlew :automation-server:assembleDebug :automation-server:assembleDebugAndroidTest" + + val androidDevice = android as? Android + ?: return@tool "Android device configuration not available" + + val resolvedMainApk = discovery.resolveMainApkPath(apkPath) + if (resolvedMainApk != null) { + androidDevice.executeAdb("install", "-r", resolvedMainApk) + } else { + return@tool "Main APK not found at the expected path derived from test APK: $apkPath. Ensure the main automation-server APK is built/installed (e.g., via :automation-server:assembleDebug), or re-run install.sh or set VISION_TEST_APK_PATH." + } + + androidDevice.executeAdb("install", "-r", apkPath) + + "Automation server APKs installed successfully on device ${device.id}. Use 'start_automation_server' to start the server." + } + } + + private fun registerStartAutomationServer(scope: ToolScope) { + scope.tool( + name = "start_automation_server", + description = "Starts the automation server on the connected Android device. The APKs must be installed first using install_automation_server. Sets up port forwarding and starts the instrumentation server.", + timeoutMs = 30000 + ) { + val device = android.getFirstAvailableDevice() + val androidDevice = android as? Android + ?: return@tool "Android device configuration not available" + + val port = AutomationConfig.DEFAULT_PORT + + if (automationClient.isServerRunning()) { + return@tool "Automation server is already running on localhost:$port" + } + + androidDevice.executeAdb("forward", "tcp:$port", "tcp:$port") + + withContext(Dispatchers.IO) { + val command = listOf( + "adb", "-s", device.id, "shell", + "am", "instrument", "-w", + "-e", "port", port.toString(), + "-e", "class", AutomationConfig.AUTOMATION_SERVER_TEST_CLASS, + "${AutomationConfig.AUTOMATION_SERVER_TEST_PACKAGE}/${AutomationConfig.INSTRUMENTATION_RUNNER}" + ) + ProcessBuilder(command) + .redirectErrorStream(true) + .start() + } + + var attempts = 0 + val maxAttempts = 10 + while (attempts < maxAttempts) { + delay(500) + if (automationClient.isServerRunning()) { + return@tool "Automation server started successfully on device ${device.id}. Server is listening on localhost:$port" + } + attempts++ + } + + "Automation server may not have started properly. Check device logs with: adb logcat | grep AutomationServer" + } + } + + private fun registerAutomationServerStatus(scope: ToolScope) { + scope.tool( + name = "automation_server_status", + description = "Checks if the automation server is running on the connected Android device. Returns server status and connection information." + ) { + val isRunning = automationClient.isServerRunning() + if (isRunning) { + "Automation server is running and accessible at localhost:${AutomationConfig.DEFAULT_PORT}" + } else { + "Automation server is not running. Use 'start_automation_server' to start it." + } + } + } + + private fun registerGetUiHierarchy(scope: ToolScope) { + scope.tool( + name = "get_ui_hierarchy", + description = """ + Gets the COMPLETE UI hierarchy as XML from the current screen. + The automation server must be running first (use start_automation_server). + + PREFER 'get_interactive_elements' for most tasks - it returns a cleaner, + filtered list of elements you can actually interact with. + + USE THIS TOOL WHEN YOU NEED: + - Full XML structure with parent-child relationships + - Debug why an element isn't found by get_interactive_elements + - Analyze layout containers and view hierarchy + - Find elements with unusual/custom class names + - Inspect raw accessibility properties + + RETURNS: Verbose XML with ALL elements including: + - Layout containers (FrameLayout, LinearLayout, etc.) + - Invisible or disabled elements + - Every node in the accessibility tree + + TIP: Start with get_interactive_elements. Only use this if you need + the full structure or can't find what you're looking for. + """.trimIndent(), + timeoutMs = 30000 + ) { + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + automationClient.getUiHierarchy() + } + } + + private fun registerFindElement(scope: ToolScope) { + scope.tool( + name = "find_element", + description = """ + Finds a UI element on the current screen of the connected Android device. + Returns element info including bounds, text, and properties if found. + The automation server must be running first (use start_automation_server). + + Provide at least ONE of these parameters: + - text: Exact text match + - textContains: Partial text match + - resourceId: Resource ID (e.g., "com.example:id/button") + - className: Class name (e.g., "android.widget.Button") + - contentDescription: Accessibility content description + + FLUTTER APP TIP: Flutter apps expose text labels via 'contentDescription' instead of 'text'. + If you cannot find an element by 'text', retry using 'contentDescription' with the same value. + """.trimIndent(), + timeoutMs = 30000 + ) { request -> + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + + val text = request.optionalString("text") + val textContains = request.optionalString("textContains") + val resourceId = request.optionalString("resourceId") + val className = request.optionalString("className") + val contentDescription = request.optionalString("contentDescription") + + if (text == null && textContains == null && resourceId == null && + className == null && contentDescription == null) { + return@tool "Error: At least one selector required (text, textContains, resourceId, className, or contentDescription)" + } + + automationClient.findElement( + text = text, + textContains = textContains, + resourceId = resourceId, + className = className, + contentDescription = contentDescription + ) + } + } + + private fun registerTapByCoordinates(scope: ToolScope) { + scope.tool( + name = "android_tap_by_coordinates", + description = """ + Tap on the Android device screen at the specified (x, y) coordinates. + The automation server must be running first (use start_automation_server). + + WORKFLOW: Prefer calling 'get_interactive_elements' first to locate tappable elements. + Each interactive element includes ready-to-use 'centerX' and 'centerY' fields that you can pass + directly as the 'x' and 'y' parameters to this tool. + If you instead use 'get_ui_hierarchy' or 'find_element', elements expose bounds in the format + [left,top][right,bottom] (e.g., [100,200][300,400]). In that case, manually calculate center + coordinates: x = (left + right) / 2, y = (top + bottom) / 2. + Example (manual calculation): For bounds [100,200][300,400], tap at x=200, y=300. + + USE CASES: + - Tap buttons, links, or interactive elements after locating them + - Tap at specific screen positions for gestures or navigation + + TIP: If you know the element's text or resourceId, use 'find_element' first + to get precise bounds rather than guessing coordinates. + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("x", "y")) + ) { request -> + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + val x = request.requireInt("x") + val y = request.requireInt("y") + automationClient.tapByCoordinates(x, y) + } + } + + private fun registerSwipe(scope: ToolScope) { + scope.tool( + name = "android_swipe", + description = """ + Swipe on the Android device screen from one point to another. + The automation server must be running first (use start_automation_server). + + PARAMETERS: + - startX, startY: Starting coordinates of the swipe + - endX, endY: Ending coordinates of the swipe + - steps (optional, default 20): Number of steps in the swipe gesture. + Controls speed and smoothness: lower = faster (10 for quick), higher = slower (50-100 for scrolling). + + USE CASES: + - Scroll lists: swipe from bottom to top (e.g., startY=1500, endY=500) + - Navigate carousels: swipe horizontally + - Pull-to-refresh: swipe from top to bottom + + EXAMPLE: To scroll down on a 1080x1920 screen, swipe from (540, 1400) to (540, 600). + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("startX", "startY", "endX", "endY")) + ) { request -> + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + val startX = request.requireInt("startX") + val startY = request.requireInt("startY") + val endX = request.requireInt("endX") + val endY = request.requireInt("endY") + val steps = request.optionalInt("steps") ?: 20 + automationClient.swipe(startX, startY, endX, endY, steps) + } + } + + private fun registerSwipeDirection(scope: ToolScope) { + scope.tool( + name = "android_swipe_direction", + description = """ + Swipe in a direction on the Android device screen. + The automation server must be running first (use start_automation_server). + + SIMPLER than 'android_swipe' - no need to calculate coordinates! + Automatically uses screen center and calculates start/end points. + + PARAMETERS: + - direction (required): "up", "down", "left", "right" + - distance (optional): "short" (20%), "medium" (40%, default), "long" (60%) + - speed (optional): "slow", "normal" (default), "fast" + + DIRECTION BEHAVIOR: + - "up" → Finger moves up, content scrolls DOWN (see more below) + - "down" → Finger moves down, content scrolls UP (see more above) + - "left" → Finger moves left (next item in carousel) + - "right" → Finger moves right (previous item / go back) + + EXAMPLES: + - Scroll a list down: direction="up" + - Scroll a list up: direction="down" + - Next carousel item: direction="left" + - Pull to refresh: direction="down", distance="long", speed="slow" + + USE 'android_swipe' instead when you need: + - Precise start/end coordinates + - Swipe from a specific element + - Diagonal swipes + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("direction")) + ) { request -> + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + + val direction = request.requireString("direction") + val validDirections = listOf("up", "down", "left", "right") + if (direction.lowercase() !in validDirections) { + return@tool "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" + } + + val distance = request.optionalString("distance") ?: "medium" + val speed = request.optionalString("speed") ?: "normal" + + automationClient.swipeByDirection(direction, distance, speed) + } + } + + private fun registerSwipeOnElement(scope: ToolScope) { + scope.tool( + name = "android_swipe_on_element", + description = """ + Swipe on a specific UI element in a direction. + The automation server must be running first (use start_automation_server). + + PERFECT FOR: + - Carousels and horizontal scrollers + - ViewPagers and tab swiping + - Sliders and seek bars + - Any scrollable element that isn't full-screen + + PARAMETERS: + - direction (required): "up", "down", "left", "right" + - At least ONE selector (to find the element): + - resourceId: e.g., "com.example:id/carousel" + - text: Exact text match + - textContains: Partial text match + - className: e.g., "androidx.recyclerview.widget.RecyclerView" + - contentDescription: Accessibility label + - speed (optional): "slow", "normal" (default), "fast" + + HOW IT WORKS: + 1. Finds the element using the provided selector + 2. Calculates swipe coordinates within the element's bounds + 3. Performs the swipe (70% of element dimension) + + EXAMPLES: + - Carousel next: resourceId="carousel", direction="left" + - Carousel previous: resourceId="carousel", direction="right" + - Scroll list in container: className="RecyclerView", direction="up" + + USE 'android_swipe_direction' for full-screen scrolling. + USE 'android_swipe' for precise coordinate control. + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("direction")) + ) { request -> + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + + val direction = request.requireString("direction") + val validDirections = listOf("up", "down", "left", "right") + if (direction.lowercase() !in validDirections) { + return@tool "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" + } + + val text = request.optionalString("text") + val textContains = request.optionalString("textContains") + val resourceId = request.optionalString("resourceId") + val className = request.optionalString("className") + val contentDescription = request.optionalString("contentDescription") + + if (text == null && textContains == null && resourceId == null && + className == null && contentDescription == null) { + return@tool "Error: At least one selector required (text, textContains, resourceId, className, or contentDescription)" + } + + val speed = request.optionalString("speed") ?: "normal" + + automationClient.swipeOnElement( + direction = direction, + text = text, + textContains = textContains, + resourceId = resourceId, + className = className, + contentDescription = contentDescription, + speed = speed + ) + } + } + + private fun registerPressBack(scope: ToolScope) { + scope.tool( + name = "android_press_back", + description = """ + Press the hardware back button on the Android device. + The automation server must be running first (use start_automation_server). + + USE CASES: + - Navigate to the previous screen in an app + - Dismiss dialogs, popups, or bottom sheets + - Close the on-screen keyboard + - Exit full-screen or immersive modes + - Cancel ongoing operations (e.g., close a search bar) + + BEHAVIOR: + - Equivalent to pressing the physical/virtual back button + - Apps may intercept this action for custom behavior + - Multiple presses may be needed to fully exit nested screens + + TIP: Use 'get_ui_hierarchy' after pressing back to verify the expected + screen is now displayed before proceeding with further actions. + """.trimIndent() + ) { + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + automationClient.pressBack() + } + } + + private fun registerPressHome(scope: ToolScope) { + scope.tool( + name = "android_press_home", + description = """ + Press the hardware home button on the Android device. + The automation server must be running first (use start_automation_server). + + USE CASES: + - Return to the device home screen from any app + - Minimize the current app without closing it + - Exit immersive or full-screen modes + - Reset navigation state to a known starting point + - Switch context before launching a different app + + BEHAVIOR: + - Equivalent to pressing the physical/virtual home button + - The current app moves to the background (not terminated) + - Always navigates to the launcher/home screen + - Works regardless of app state or navigation depth + + TIP: Use 'get_ui_hierarchy' after pressing home to confirm you're on the + home screen, then use 'launch_app_android' to start a different app. + """.trimIndent() + ) { + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + automationClient.pressHome() + } + } + + private fun registerInputText(scope: ToolScope) { + scope.tool( + name = "android_input_text", + description = """ + Types text into the currently focused element on the Android device. + The automation server must be running first (use start_automation_server). + + WORKFLOW: First tap on a text field using 'android_tap_by_coordinates' to focus it, + then call this tool to type text into it. + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("text")) + ) { request -> + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + val text = request.requireString("text") + automationClient.inputText(text) + } + } + + private fun registerGetDeviceInfo(scope: ToolScope) { + scope.tool( + name = "android_get_device_info", + description = """ + Gets device information from the connected Android device via the automation server. + The automation server must be running first (use start_automation_server). + + RETURNS: + - Display size (width x height in pixels) + - Display rotation (0, 90, 180, or 270 degrees) + - SDK version (Android API level) + + USE CASES: + - Determine screen dimensions for calculating tap/swipe coordinates + - Check device orientation before performing gestures + - Verify SDK version for feature compatibility + """.trimIndent() + ) { + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + automationClient.getDeviceInfo() + } + } + + private fun registerGetInteractiveElements(scope: ToolScope) { + scope.tool( + name = "get_interactive_elements", + description = """ + Gets a filtered list of interactive UI elements from the current screen. + The automation server must be running first (use start_automation_server). + + MUCH MORE USEFUL than 'get_ui_hierarchy' for most tasks because it: + - Returns only elements you can actually interact with + - Filters out layout containers and invisible elements + - Provides center coordinates ready for tapping + - Returns clean JSON instead of verbose XML + + HEURISTICS USED (handles missing accessibility properties): + - Explicitly interactive: clickable, checkable, scrollable, long-clickable + - Known interactive classes: Button, EditText, CheckBox, Switch, etc. + - Has meaningful content: text, content-description, or resource-id + - Excludes: layout containers, invisible elements, disabled elements + + OPTIONAL PARAMETERS: + - includeDisabled: Set to true to also include disabled elements (default: false) + + RETURNS for each element: + - text, contentDescription, resourceId, className + - bounds (e.g., "[100,200][300,400]") + - centerX, centerY (ready for android_tap_by_coordinates) + - isClickable, isCheckable, isScrollable, isLongClickable, isEnabled + + WORKFLOW: + 1. Call get_interactive_elements to see what you can interact with + 2. Find the element you want by text, contentDescription, or resourceId + 3. Use centerX, centerY with android_tap_by_coordinates to tap it + """.trimIndent(), + timeoutMs = 30000 + ) { request -> + if (!automationClient.isServerRunning()) { + return@tool "Automation server is not running. Use 'start_automation_server' first." + } + + val includeDisabledRaw = request.optionalString("includeDisabled") + val includeDisabled = when (includeDisabledRaw) { + null -> false + "true" -> true + "false" -> false + else -> return@tool "Invalid value for 'includeDisabled': '$includeDisabledRaw'. Must be true or false." + } + + automationClient.getInteractiveElements(includeDisabled) + } + } +} diff --git a/app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt new file mode 100644 index 0000000..8436f77 --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt @@ -0,0 +1,87 @@ +package com.example.visiontest.tools + +import com.example.visiontest.common.DeviceConfig +import com.example.visiontest.utils.ErrorHandler.PACKAGE_NAME_REQUIRED +import io.modelcontextprotocol.kotlin.sdk.Tool + +class AndroidDeviceToolRegistrar( + private val android: DeviceConfig +) : ToolRegistrar { + + companion object { + private const val PROP_MODEL = "ro.product.model" + private const val PROP_ANDROID_VERSION = "ro.build.version.release" + private const val PROP_SDK_VERSION = "ro.build.version.sdk" + } + + override fun registerTools(scope: ToolScope) { + registerAvailableDevice(scope) + registerListApps(scope) + registerInfoApp(scope) + registerLaunchApp(scope) + } + + private fun registerAvailableDevice(scope: ToolScope) { + scope.tool( + name = "available_device_android", + description = "Returns detailed information about the first available Android device, including model, Android version, SDK version, and device state. Automatically selects the first active device connected via ADB." + ) { + val result = android.getFirstAvailableDevice() + val deviceProps = android.executeShell("getprop", result.id) + val modelName = ToolHelpers.extractProperty(deviceProps, PROP_MODEL) + val androidVersion = ToolHelpers.extractProperty(deviceProps, PROP_ANDROID_VERSION) + val sdkVersion = ToolHelpers.extractProperty(deviceProps, PROP_SDK_VERSION) + + """ + |Device found: + |Serial: ${result.id} + |Model: $modelName + |Android Version: $androidVersion + |SDK Version: $sdkVersion + |State: ${result.state} + """.trimMargin() + } + } + + private fun registerListApps(scope: ToolScope) { + scope.tool( + name = "list_apps_android", + description = "Returns a complete list of all applications installed on the Android device. Returns package names (e.g., com.example.app) for all installed apps." + ) { + val result = android.listApps() + if (result.isEmpty()) { + "No apps found on the device" + } else { + "Found these apps: ${result.joinToString(", ")}" + } + } + } + + private fun registerInfoApp(scope: ToolScope) { + scope.tool( + name = "info_app_android", + description = "Returns detailed information about a specific Android application. Requires 'packageName' parameter (e.g., com.example.app). Returns version info, SDK requirements, installation dates, and permissions.", + inputSchema = Tool.Input(required = listOf("packageName")) + ) { request -> + val packageName = request.requireString("packageName") + val rawResult = android.getAppInfo(packageName) + ToolHelpers.formatAppInfo(rawResult, packageName) + } + } + + private fun registerLaunchApp(scope: ToolScope) { + scope.tool( + name = "launch_app_android", + description = "Launches an Android application on the connected device. Requires 'packageName' parameter (e.g., com.example.app). Uses monkey command to launch the app's main activity.", + inputSchema = Tool.Input(required = listOf("packageName")) + ) { request -> + val packageName = request.requireString("packageName") + val result = android.launchApp(packageName) + if (result) { + "Successfully launched the app: $packageName" + } else { + "Failed to launch the app: $packageName" + } + } + } +} diff --git a/openspec/changes/refactor-toolfactory/tasks.md b/openspec/changes/refactor-toolfactory/tasks.md index a5b307b..1a03b99 100644 --- a/openspec/changes/refactor-toolfactory/tasks.md +++ b/openspec/changes/refactor-toolfactory/tasks.md @@ -16,8 +16,8 @@ ## 4. Android Registrars -- [ ] 4.1 Create `app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`. Register 4 tools: `available_device_android`, `list_apps_android`, `info_app_android`, `launch_app_android`. Use `ToolScope.tool()` DSL and `ToolHelpers` for property extraction/formatting. -- [ ] 4.2 Create `app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`, `AutomationClient`, `ToolDiscovery`. Register 14 tools: `install_automation_server`, `start_automation_server`, `automation_server_status`, `get_ui_hierarchy`, `find_element`, `android_tap_by_coordinates`, `android_swipe`, `android_swipe_direction`, `android_swipe_on_element`, `android_press_back`, `android_press_home`, `android_input_text`, `android_get_device_info`, `get_interactive_elements`. Use `ToolScope.tool()` DSL and request extension helpers. +- [x] 4.1 Create `app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`. Register 4 tools: `available_device_android`, `list_apps_android`, `info_app_android`, `launch_app_android`. Use `ToolScope.tool()` DSL and `ToolHelpers` for property extraction/formatting. +- [x] 4.2 Create `app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`, `AutomationClient`, `ToolDiscovery`. Register 14 tools: `install_automation_server`, `start_automation_server`, `automation_server_status`, `get_ui_hierarchy`, `find_element`, `android_tap_by_coordinates`, `android_swipe`, `android_swipe_direction`, `android_swipe_on_element`, `android_press_back`, `android_press_home`, `android_input_text`, `android_get_device_info`, `get_interactive_elements`. Use `ToolScope.tool()` DSL and request extension helpers. ## 5. iOS Registrars From 492c3b6a7d9823c676d01195b14234587786daa0 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 12:55:46 +0100 Subject: [PATCH 05/10] feat: extract iOS tools into device and automation registrars --- .../tools/IOSAutomationToolRegistrar.kt | 496 ++++++++++++++++++ .../tools/IOSDeviceToolRegistrar.kt | 77 +++ .../example/visiontest/ToolFactoryPathTest.kt | 17 +- .../changes/refactor-toolfactory/tasks.md | 4 +- 4 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt create mode 100644 app/src/main/kotlin/com/example/visiontest/tools/IOSDeviceToolRegistrar.kt diff --git a/app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt new file mode 100644 index 0000000..2887d54 --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt @@ -0,0 +1,496 @@ +package com.example.visiontest.tools + +import com.example.visiontest.common.DeviceConfig +import com.example.visiontest.config.IOSAutomationConfig +import com.example.visiontest.discovery.ToolDiscovery +import com.example.visiontest.ios.IOSAutomationClient +import io.modelcontextprotocol.kotlin.sdk.Tool +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.slf4j.Logger + +class IOSAutomationToolRegistrar( + private val ios: DeviceConfig, + private val iosAutomationClient: IOSAutomationClient, + private val discovery: ToolDiscovery, + private val logger: Logger +) : ToolRegistrar { + + @Volatile + private var iosXcodebuildProcess: Process? = null + + override fun registerTools(scope: ToolScope) { + registerStartAutomationServer(scope) + registerAutomationServerStatus(scope) + registerGetUiHierarchy(scope) + registerGetInteractiveElements(scope) + registerTapByCoordinates(scope) + registerSwipe(scope) + registerSwipeDirection(scope) + registerFindElement(scope) + registerGetDeviceInfo(scope) + registerPressHome(scope) + registerInputText(scope) + registerStopAutomationServer(scope) + } + + // ==================== Server Process Management ==================== + + internal fun buildXcodebuildCommand( + xctestrunPath: String?, + projectPath: String?, + simulatorName: String + ): List { + val testId = "${IOSAutomationConfig.UI_TEST_TARGET}/${IOSAutomationConfig.TEST_CLASS}/${IOSAutomationConfig.TEST_METHOD}" + return if (xctestrunPath != null) { + listOf( + "xcodebuild", "test-without-building", + "-xctestrun", xctestrunPath, + "-destination", "platform=iOS Simulator,name=$simulatorName", + "-only-testing:$testId" + ) + } else { + val resolvedProject = requireNotNull(projectPath) { + "projectPath must be set when xctestrunPath is null" + } + listOf( + "xcodebuild", "test", + "-project", resolvedProject, + "-scheme", IOSAutomationConfig.XCODE_SCHEME, + "-destination", "platform=iOS Simulator,name=$simulatorName", + "-only-testing:$testId" + ) + } + } + + private data class ServerPollResult( + val message: String, + val earlyExitCode: Int? = null + ) + + /** Formats a command list as a shell-safe string, quoting arguments that contain spaces or special characters. */ + private fun shellQuote(command: List): String { + return command.joinToString(" ") { arg -> + if (arg.any { it.isWhitespace() || it in setOf('(', ')', '&', '|', ';', '*', '?', '<', '>', '$', '!', '`', '"') }) { + "'${arg.replace("'", "'\\''")}'" + } else { + arg + } + } + } + + private suspend fun startAndPollServer( + command: List, + maxAttempts: Int, + port: Int, + label: String + ): ServerPollResult { + withContext(Dispatchers.IO) { + logger.info("Starting iOS automation server ($label): ${command.joinToString(" ")}") + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start() + iosXcodebuildProcess = process + } + + var attempts = 0 + while (attempts < maxAttempts) { + delay(2000) + + val process = iosXcodebuildProcess + if (process != null && !process.isAlive) { + val exitCode = process.exitValue() + logger.warn("xcodebuild ($label) exited early with code $exitCode") + iosXcodebuildProcess = null + return ServerPollResult( + message = "xcodebuild ($label) exited with code $exitCode before the server started. " + + "Run manually to see errors:\n${shellQuote(command)}", + earlyExitCode = exitCode + ) + } + + if (iosAutomationClient.isServerRunning()) { + logger.info("iOS automation server started successfully ($label)") + return ServerPollResult("iOS automation server started successfully ($label). Server is listening on localhost:$port") + } + attempts++ + logger.debug("Waiting for iOS server to start ($label)... attempt $attempts/$maxAttempts") + } + + return ServerPollResult("iOS automation server did not respond after ${maxAttempts * 2}s. xcodebuild may still be building. Check with 'ios_automation_server_status' or run xcodebuild manually to see output.") + } + + // ==================== Tool Registrations ==================== + + private fun registerStartAutomationServer(scope: ToolScope) { + scope.tool( + name = "ios_start_automation_server", + description = """ + Starts the iOS automation server on the booted iOS simulator. + Uses pre-built test bundle if available, otherwise builds from source. + + The server starts on port ${IOSAutomationConfig.DEFAULT_PORT} and is directly + accessible at localhost (no port forwarding needed for iOS simulators). + """.trimIndent(), + timeoutMs = 200000 + ) { + val port = IOSAutomationConfig.DEFAULT_PORT + + if (iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is already running on localhost:$port" + } + + // Clean up any orphaned previous process + iosXcodebuildProcess?.let { process -> + if (process.isAlive) { + logger.info("Destroying orphaned xcodebuild process before starting a new one") + process.destroyForcibly() + } + iosXcodebuildProcess = null + } + + // Discover launch path: pre-built bundle preferred, source build as fallback + val xctestrunPath = discovery.findXctestrun() + val projectPath = discovery.findXcodeProject() + + if (xctestrunPath == null && projectPath == null) { + return@tool "Neither pre-built iOS test bundle nor Xcode source project found. " + + "To fix: re-run install.sh on macOS to download the pre-built bundle, " + + "or clone the VisionTest repository and set ${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV} " + + "to build from source." + } + + val usingPrebuilt = xctestrunPath != null + if (usingPrebuilt) { + logger.info("Using pre-built iOS test bundle: $xctestrunPath") + } else { + logger.info("Using source build from Xcode project: $projectPath") + } + + val device = ios.getFirstAvailableDevice() + val simulatorName = device.name + + val command = buildXcodebuildCommand(xctestrunPath, projectPath, simulatorName) + val maxAttempts = if (usingPrebuilt) 30 else 60 + + val label = if (usingPrebuilt) "pre-built bundle" else "source build" + val primaryResult = startAndPollServer(command, maxAttempts, port, label) + + if (primaryResult.earlyExitCode == null) { + return@tool primaryResult.message + } + + // Primary attempt exited early — try source build fallback if available + if (usingPrebuilt && projectPath != null) { + logger.warn("Pre-built bundle failed (exit code ${primaryResult.earlyExitCode}), falling back to source build") + val fallbackCommand = buildXcodebuildCommand(null, projectPath, simulatorName) + val fallbackResult = startAndPollServer(fallbackCommand, 60, port, "source build fallback") + return@tool fallbackResult.message + } + + primaryResult.message + } + } + + private fun registerAutomationServerStatus(scope: ToolScope) { + scope.tool( + name = "ios_automation_server_status", + description = "Checks if the iOS automation server is running on the simulator. Returns server status and connection information." + ) { + val isRunning = iosAutomationClient.isServerRunning() + if (isRunning) { + "iOS automation server is running and accessible at localhost:${IOSAutomationConfig.DEFAULT_PORT}" + } else { + "iOS automation server is not running. Use 'ios_start_automation_server' to start it." + } + } + } + + private fun registerGetUiHierarchy(scope: ToolScope) { + scope.tool( + name = "ios_get_ui_hierarchy", + description = """ + Gets the COMPLETE UI hierarchy as XML from the current iOS simulator screen. + The iOS automation server must be running first (use ios_start_automation_server). + + PREFER 'ios_get_interactive_elements' for most tasks - it returns a cleaner, + filtered list of elements you can actually interact with. + + USE THIS TOOL WHEN YOU NEED: + - Full XML structure with parent-child relationships + - Debug why an element isn't found by ios_get_interactive_elements + - Analyze layout structure + - Inspect raw accessibility properties + + OPTIONAL PARAMETERS: + - bundleId: Bundle ID of the app to query (e.g., "com.apple.Preferences"). + If not provided, queries springboard (which only shows system UI, not app content). + ALWAYS provide bundleId when inspecting an app's UI. + """.trimIndent(), + timeoutMs = 30000 + ) { request -> + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + val bundleId = request.optionalString("bundleId") + iosAutomationClient.getUiHierarchy(bundleId) + } + } + + private fun registerGetInteractiveElements(scope: ToolScope) { + scope.tool( + name = "ios_get_interactive_elements", + description = """ + Gets a filtered list of interactive UI elements from the current iOS simulator screen. + The iOS automation server must be running first (use ios_start_automation_server). + + Returns only elements you can interact with (buttons, text fields, switches, etc.) + with center coordinates ready for tapping via ios_tap_by_coordinates. + + OPTIONAL PARAMETERS: + - includeDisabled: Set to true to include disabled elements (default: false) + - bundleId: Bundle ID of the app to query (e.g., "com.apple.Preferences"). + If not provided, queries springboard (which only shows system UI, not app content). + ALWAYS provide bundleId when inspecting an app's UI. + + WORKFLOW: + 1. Call ios_get_interactive_elements with bundleId to see what you can interact with + 2. Find the element by text, label, or identifier + 3. Use centerX, centerY with ios_tap_by_coordinates to tap it + """.trimIndent(), + timeoutMs = 30000 + ) { request -> + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + + val includeDisabledRaw = request.optionalString("includeDisabled") + val includeDisabled = when (includeDisabledRaw) { + null -> false + "true" -> true + "false" -> false + else -> return@tool "Invalid value for 'includeDisabled': '$includeDisabledRaw'. Must be true or false." + } + val bundleId = request.optionalString("bundleId") + + iosAutomationClient.getInteractiveElements(includeDisabled, bundleId) + } + } + + private fun registerTapByCoordinates(scope: ToolScope) { + scope.tool( + name = "ios_tap_by_coordinates", + description = """ + Tap on the iOS simulator screen at the specified (x, y) coordinates. + The iOS automation server must be running first (use ios_start_automation_server). + + WORKFLOW: First call 'ios_get_interactive_elements' to locate the target element. + Use the centerX, centerY values from the returned elements. + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("x", "y")) + ) { request -> + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + val x = request.requireInt("x") + val y = request.requireInt("y") + iosAutomationClient.tapByCoordinates(x, y) + } + } + + private fun registerSwipe(scope: ToolScope) { + scope.tool( + name = "ios_swipe", + description = """ + Swipe on the iOS simulator screen from one point to another. + The iOS automation server must be running first (use ios_start_automation_server). + + PARAMETERS: + - startX, startY: Starting coordinates + - endX, endY: Ending coordinates + - steps (optional, default 20): Controls speed (maps to duration: steps * 0.05 seconds) + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("startX", "startY", "endX", "endY")) + ) { request -> + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + val startX = request.requireInt("startX") + val startY = request.requireInt("startY") + val endX = request.requireInt("endX") + val endY = request.requireInt("endY") + val steps = request.optionalInt("steps") ?: 20 + iosAutomationClient.swipe(startX, startY, endX, endY, steps) + } + } + + private fun registerSwipeDirection(scope: ToolScope) { + scope.tool( + name = "ios_swipe_direction", + description = """ + Swipe in a direction on the iOS simulator screen. + The iOS automation server must be running first (use ios_start_automation_server). + + SIMPLER than 'ios_swipe' - no need to calculate coordinates! + + PARAMETERS: + - direction (required): "up", "down", "left", "right" + - distance (optional): "short" (20%), "medium" (40%, default), "long" (60%) + - speed (optional): "slow", "normal" (default), "fast" + + DIRECTION BEHAVIOR: + - "up" → Finger moves up, content scrolls DOWN + - "down" → Finger moves down, content scrolls UP + - "left" → Finger moves left (next item) + - "right" → Finger moves right (previous item) + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("direction")) + ) { request -> + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + + val direction = request.requireString("direction") + val validDirections = listOf("up", "down", "left", "right") + if (direction.lowercase() !in validDirections) { + return@tool "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" + } + + val distance = request.optionalString("distance") ?: "medium" + val speed = request.optionalString("speed") ?: "normal" + + iosAutomationClient.swipeByDirection(direction, distance, speed) + } + } + + private fun registerFindElement(scope: ToolScope) { + scope.tool( + name = "ios_find_element", + description = """ + Finds a UI element on the current iOS simulator screen. + Returns element info including bounds, text, and properties if found. + The iOS automation server must be running first (use ios_start_automation_server). + + Provide at least ONE of these parameters: + - text: Exact text match + - textContains: Partial text match + - resourceId: Accessibility identifier + - className: Element type name (e.g., "Button", "TextField") + - contentDescription: Accessibility label + - bundleId: Bundle ID of the app to search in (e.g., "com.apple.Preferences"). + If not provided, searches springboard. ALWAYS provide bundleId when searching in an app. + """.trimIndent(), + timeoutMs = 30000 + ) { request -> + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + + val text = request.optionalString("text") + val textContains = request.optionalString("textContains") + val identifier = request.optionalString("resourceId") + val elementType = request.optionalString("className") + val label = request.optionalString("contentDescription") + val bundleId = request.optionalString("bundleId") + + if (text == null && textContains == null && identifier == null && + elementType == null && label == null) { + return@tool "Error: At least one selector required (text, textContains, resourceId, className, or contentDescription)" + } + + iosAutomationClient.findElement( + text = text, + textContains = textContains, + identifier = identifier, + elementType = elementType, + label = label, + bundleId = bundleId + ) + } + } + + private fun registerGetDeviceInfo(scope: ToolScope) { + scope.tool( + name = "ios_get_device_info", + description = """ + Gets device information from the iOS simulator via the automation server. + The iOS automation server must be running first (use ios_start_automation_server). + + RETURNS: + - Display size (width x height in pixels) + - Display rotation + - iOS version + - Device model + """.trimIndent() + ) { + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + iosAutomationClient.getDeviceInfo() + } + } + + private fun registerPressHome(scope: ToolScope) { + scope.tool( + name = "ios_press_home", + description = """ + Press the home button on the iOS simulator. + The iOS automation server must be running first (use ios_start_automation_server). + + Returns to the home screen. The current app moves to the background. + """.trimIndent() + ) { + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + iosAutomationClient.pressHome() + } + } + + private fun registerInputText(scope: ToolScope) { + scope.tool( + name = "ios_input_text", + description = """ + Types text into the currently focused element on the iOS simulator. + The iOS automation server must be running first (use ios_start_automation_server). + + WORKFLOW: First tap on a text field using 'ios_tap_by_coordinates' to focus it, + then call this tool to type text into it. + + PARAMETERS: + - text (required): The text to type. + - bundleId (required for app UI): Bundle ID of the target app (e.g., "com.apple.Preferences"). + ALWAYS provide bundleId when typing into a third-party or system app. + Without bundleId, the server targets Springboard and will fail if no focused element is found. + Only omit bundleId when interacting with Springboard system UI itself. + """.trimIndent(), + inputSchema = Tool.Input(required = listOf("text")) + ) { request -> + if (!iosAutomationClient.isServerRunning()) { + return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." + } + val text = request.requireString("text") + val bundleId = request.optionalString("bundleId") + iosAutomationClient.inputText(text, bundleId) + } + } + + private fun registerStopAutomationServer(scope: ToolScope) { + scope.tool( + name = "ios_stop_automation_server", + description = "Stops the iOS automation server running on the simulator." + ) { + val process = iosXcodebuildProcess + if (process != null && process.isAlive) { + process.destroyForcibly() + iosXcodebuildProcess = null + "iOS automation server stopped successfully." + } else { + iosXcodebuildProcess = null + "iOS automation server is not running." + } + } + } +} diff --git a/app/src/main/kotlin/com/example/visiontest/tools/IOSDeviceToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/IOSDeviceToolRegistrar.kt new file mode 100644 index 0000000..93b9b9f --- /dev/null +++ b/app/src/main/kotlin/com/example/visiontest/tools/IOSDeviceToolRegistrar.kt @@ -0,0 +1,77 @@ +package com.example.visiontest.tools + +import com.example.visiontest.common.DeviceConfig +import io.modelcontextprotocol.kotlin.sdk.Tool + +class IOSDeviceToolRegistrar( + private val ios: DeviceConfig +) : ToolRegistrar { + + override fun registerTools(scope: ToolScope) { + registerAvailableDevice(scope) + registerListApps(scope) + registerInfoApp(scope) + registerLaunchApp(scope) + } + + private fun registerAvailableDevice(scope: ToolScope) { + scope.tool( + name = "ios_available_device", + description = "Returns detailed information about the first available iOS device or simulator. Includes device ID, name, type, state (Booted/Shutdown), iOS version, and model. Prioritizes booted simulators over shutdown ones." + ) { + val device = ios.getFirstAvailableDevice() + + """ + |iOS Device found: + |ID: ${device.id} + |Name: ${device.name} + |Type: ${device.type} + |State: ${device.state} + |OS Version: ${device.osVersion ?: "Unknown"} + |Model: ${device.modelName ?: "Unknown"} + """.trimMargin() + } + } + + private fun registerListApps(scope: ToolScope) { + scope.tool( + name = "ios_list_apps", + description = "Returns a complete list of all applications installed on the iOS device or simulator. Returns bundle IDs (e.g., com.apple.mobilesafari) for all installed apps. Device must be booted." + ) { + val result = ios.listApps() + if (result.isEmpty()) { + "No apps found on the iOS device" + } else { + "Found these apps: ${result.joinToString(", ")}" + } + } + } + + private fun registerInfoApp(scope: ToolScope) { + scope.tool( + name = "ios_info_app", + description = "Returns detailed information about a specific iOS application. Requires 'bundleId' parameter (e.g., com.apple.mobilesafari). Returns bundle ID and app container path. Device must be booted.", + inputSchema = Tool.Input(required = listOf("bundleId")) + ) { request -> + val bundleId = request.requireString("bundleId") + val rawResult = ios.getAppInfo(bundleId) + "App Information for $bundleId:\n$rawResult" + } + } + + private fun registerLaunchApp(scope: ToolScope) { + scope.tool( + name = "ios_launch_app", + description = "Launches an iOS application on the device or simulator. Requires 'bundleId' parameter (e.g., com.apple.mobilesafari). Device must be booted before launching apps.", + inputSchema = Tool.Input(required = listOf("bundleId")) + ) { request -> + val bundleId = request.requireString("bundleId") + val result = ios.launchApp(bundleId) + if (result) { + "Successfully launched the iOS app: $bundleId" + } else { + "Failed to launch the iOS app: $bundleId" + } + } + } +} diff --git a/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt b/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt index 95cbfca..b442d6d 100644 --- a/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt +++ b/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt @@ -2,6 +2,8 @@ package com.example.visiontest import com.example.visiontest.common.DeviceConfig import com.example.visiontest.discovery.ToolDiscovery +import com.example.visiontest.ios.IOSAutomationClient +import com.example.visiontest.tools.IOSAutomationToolRegistrar import io.mockk.mockk import org.junit.jupiter.api.io.TempDir import org.slf4j.LoggerFactory @@ -17,12 +19,10 @@ class ToolFactoryPathTest { private val logger = LoggerFactory.getLogger("test") private val discovery = ToolDiscovery(logger) - // ToolFactory still needed for buildXcodebuildCommand tests (moves in a later step) - private val mockAndroid = mockk(relaxed = true) - private val mockIos = mockk(relaxed = true) - private val factory = ToolFactory( - android = mockAndroid, - ios = mockIos, + private val iosRegistrar = IOSAutomationToolRegistrar( + ios = mockk(relaxed = true), + iosAutomationClient = mockk(relaxed = true), + discovery = discovery, logger = logger ) @@ -446,11 +446,10 @@ class ToolFactoryPathTest { } // ==================== buildXcodebuildCommand ==================== - // These tests still reference ToolFactory — will move to IOSAutomationToolRegistrar tests later @Test fun `buildXcodebuildCommand produces test-without-building for pre-built path`() { - val command = factory.buildXcodebuildCommand( + val command = iosRegistrar.buildXcodebuildCommand( xctestrunPath = "/path/to/Test.xctestrun", projectPath = null, simulatorName = "iPhone 16" @@ -465,7 +464,7 @@ class ToolFactoryPathTest { @Test fun `buildXcodebuildCommand produces test for source path`() { - val command = factory.buildXcodebuildCommand( + val command = iosRegistrar.buildXcodebuildCommand( xctestrunPath = null, projectPath = "/path/to/Project.xcodeproj", simulatorName = "iPhone 16" diff --git a/openspec/changes/refactor-toolfactory/tasks.md b/openspec/changes/refactor-toolfactory/tasks.md index 1a03b99..77ff873 100644 --- a/openspec/changes/refactor-toolfactory/tasks.md +++ b/openspec/changes/refactor-toolfactory/tasks.md @@ -21,8 +21,8 @@ ## 5. iOS Registrars -- [ ] 5.1 Create `app/src/main/kotlin/com/example/visiontest/tools/IOSDeviceToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`. Register 4 tools: `ios_available_device`, `ios_list_apps`, `ios_info_app`, `ios_launch_app`. Use `ToolScope.tool()` DSL. -- [ ] 5.2 Create `app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`, `IOSAutomationClient`, `ToolDiscovery`. Move `@Volatile iosXcodebuildProcess`, `buildXcodebuildCommand()`, `shellQuote()`, `startAndPollServer()`, `ServerPollResult` into this class. Register 12 tools: `ios_start_automation_server`, `ios_automation_server_status`, `ios_get_ui_hierarchy`, `ios_get_interactive_elements`, `ios_tap_by_coordinates`, `ios_swipe`, `ios_swipe_direction`, `ios_find_element`, `ios_get_device_info`, `ios_press_home`, `ios_input_text`, `ios_stop_automation_server`. Use `ToolScope.tool()` DSL. +- [x] 5.1 Create `app/src/main/kotlin/com/example/visiontest/tools/IOSDeviceToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`. Register 4 tools: `ios_available_device`, `ios_list_apps`, `ios_info_app`, `ios_launch_app`. Use `ToolScope.tool()` DSL. +- [x] 5.2 Create `app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt` implementing `ToolRegistrar`. Constructor takes `DeviceConfig`, `IOSAutomationClient`, `ToolDiscovery`. Move `@Volatile iosXcodebuildProcess`, `buildXcodebuildCommand()`, `shellQuote()`, `startAndPollServer()`, `ServerPollResult` into this class. Register 12 tools: `ios_start_automation_server`, `ios_automation_server_status`, `ios_get_ui_hierarchy`, `ios_get_interactive_elements`, `ios_tap_by_coordinates`, `ios_swipe`, `ios_swipe_direction`, `ios_find_element`, `ios_get_device_info`, `ios_press_home`, `ios_input_text`, `ios_stop_automation_server`. Use `ToolScope.tool()` DSL. ## 6. Wire Up and Replace From f956e485eeeac264523f8d4c1d258faf805e8b08 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 13:00:31 +0100 Subject: [PATCH 06/10] refactor: replace 1975-line ToolFactory with thin coordinator --- .../com/example/visiontest/ToolFactory.kt | 1967 +---------------- .../changes/refactor-toolfactory/tasks.md | 4 +- 2 files changed, 15 insertions(+), 1956 deletions(-) diff --git a/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt b/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt index 30c2e61..1dd534e 100644 --- a/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt +++ b/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt @@ -1,24 +1,12 @@ package com.example.visiontest -import com.example.visiontest.android.Android import com.example.visiontest.android.AutomationClient -import com.example.visiontest.ios.IOSManager -import com.example.visiontest.ios.IOSAutomationClient import com.example.visiontest.common.DeviceConfig -import com.example.visiontest.utils.ErrorHandler -import io.modelcontextprotocol.kotlin.sdk.* -import io.modelcontextprotocol.kotlin.sdk.server.* -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.jsonPrimitive +import com.example.visiontest.discovery.ToolDiscovery +import com.example.visiontest.ios.IOSAutomationClient +import com.example.visiontest.tools.* +import io.modelcontextprotocol.kotlin.sdk.server.Server import org.slf4j.Logger -import com.example.visiontest.utils.ErrorHandler.PACKAGE_NAME_REQUIRED -import com.example.visiontest.utils.ErrorHandler.BUNDLE_ID_REQUIRED -import com.example.visiontest.config.AutomationConfig -import com.example.visiontest.config.IOSAutomationConfig -import java.io.File - - class ToolFactory( private val android: DeviceConfig, @@ -29,1946 +17,17 @@ class ToolFactory( private val iosAutomationClient: IOSAutomationClient = IOSAutomationClient() ) { - companion object { - // Android system property keys - private const val PROP_MODEL = "ro.product.model" - private const val PROP_ANDROID_VERSION = "ro.build.version.release" - private const val PROP_SDK_VERSION = "ro.build.version.sdk" - } - - fun registerAllTools(server: Server) { - // Android tools - registerAndroidAvailableDeviceTool(server) - registerAndroidListAppsTool(server) - registerAndroidInfoAppTool(server) - registerAndroidLaunchAppTool(server) - registerInstallAutomationServerTool(server) - registerStartAutomationServerTool(server) - registerAutomationServerStatusTool(server) - registerGetUiHierarchyTool(server) - registerFindElementTool(server) - registerAndroidTapByCoordinatesTool(server) - registerAndroidPressBackTool(server) - registerAndroidPressHomeTool(server) - registerAndroidInputTextTool(server) - registerAndroidSwipe(server) - registerAndroidSwipeDirection(server) - registerAndroidSwipeOnElement(server) - registerAndroidGetDeviceInfoTool(server) - registerGetInteractiveElementsTool(server) - - // iOS tools - registerIOSAvailableDeviceTool(server) - registerIOSListAppsTool(server) - registerIOSInfoAppTool(server) - registerIOSLaunchAppTool(server) - - // iOS automation tools - registerIOSStartAutomationServerTool(server) - registerIOSAutomationServerStatusTool(server) - registerIOSGetUiHierarchyTool(server) - registerIOSGetInteractiveElementsTool(server) - registerIOSTapByCoordinatesTool(server) - registerIOSSwipeTool(server) - registerIOSSwipeDirectionTool(server) - registerIOSFindElementTool(server) - registerIOSGetDeviceInfoTool(server) - registerIOSPressHomeTool(server) - registerIOSInputTextTool(server) - registerIOSStopAutomationServerTool(server) - } - - private fun registerAndroidAvailableDeviceTool(server: Server) { - server.addTool( - name = "available_device_android", - description = "Returns detailed information about the first available Android device, including model, Android version, SDK version, and device state. Automatically selects the first active device connected via ADB.", - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout { - android.getFirstAvailableDevice() - } - // Fetch additional device information - val deviceProps = runWithTimeout { - android.executeShell("getprop", result.id) - } - val modelName = extractProperty(deviceProps, PROP_MODEL) - val androidVersion = extractProperty(deviceProps, PROP_ANDROID_VERSION) - val sdkVersion = extractProperty(deviceProps, PROP_SDK_VERSION) - - val deviceInfo = """ - |Device found: - |Serial: ${result.id} - |Model: $modelName - |Android Version: $androidVersion - |SDK Version: $sdkVersion - |State: ${result.state} - """.trimMargin() - - CallToolResult( - content = listOf(TextContent(deviceInfo)) - ) - } catch (e: Exception) { - handleToolError(e, "Error finding available device") - } - } - } - - private fun registerAndroidListAppsTool(server: Server) { - server.addTool( - name = "list_apps_android", - description = "Returns a complete list of all applications installed on the Android device. Returns package names (e.g., com.example.app) for all installed apps.", - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout { - android.listApps() - } - - val formattedResult = if (result.isEmpty()) { - "No apps found on the device" - } else { - "Found these apps: ${result.joinToString(", ")}" - } - - CallToolResult( - content = listOf(TextContent(formattedResult)) - ) - } catch (e: Exception) { - handleToolError(e, "Error listing apps") - } - } - } - - private fun registerAndroidInfoAppTool(server: Server) { - server.addTool( - name = "info_app_android", - description = "Returns detailed information about a specific Android application. Requires 'packageName' parameter (e.g., com.example.app). Returns version info, SDK requirements, installation dates, and permissions.", - inputSchema = Tool.Input( - required = listOf("packageName") - ) - ) { request: CallToolRequest -> - try { - val packageName = request.arguments["packageName"]?.jsonPrimitive?.content - ?: return@addTool CallToolResult( - content = listOf(TextContent(PACKAGE_NAME_REQUIRED)) - ) - - val rawResult = runWithTimeout { - android.getAppInfo(packageName) - } - - val formattedInfo = formatAppInfo(rawResult, packageName) - - CallToolResult( - content = listOf(TextContent(formattedInfo)) - ) - } catch (e: Exception) { - handleToolError(e, "Error retrieving app information") - } - } - } - - private fun registerAndroidLaunchAppTool(server: Server) { - server.addTool( - name = "launch_app_android", - description = "Launches an Android application on the connected device. Requires 'packageName' parameter (e.g., com.example.app). Uses monkey command to launch the app's main activity.", - inputSchema = Tool.Input( - required = listOf("packageName") - ) - ) { request: CallToolRequest -> - try { - val packageName = request.arguments["packageName"]?.jsonPrimitive?.content - ?: return@addTool CallToolResult( - content = listOf(TextContent(PACKAGE_NAME_REQUIRED)) - ) - - val result = runWithTimeout { - android.launchApp(packageName) - } - - val message = if (result) { - "Successfully launched the app: $packageName" - } else { - "Failed to launch the app: $packageName" - } - - CallToolResult( - content = listOf(TextContent(message)) - ) - } catch (e: Exception) { - handleToolError(e, "Error launching app") - } - } - } - - private fun runWithTimeout(block: suspend () -> T): T { - return runBlocking { - withTimeout(toolTimeoutMillis) { - block() - } - } - } - - private fun runWithTimeout(timeoutMs: Long, block: suspend () -> T): T { - return runBlocking { - withTimeout(timeoutMs) { - block() - } - } - } - - private fun handleToolError(e: Exception, context: String): CallToolResult { - return ErrorHandler.handleToolError(e, logger, context) - } - - // Helper function to extract properties from getprop output - internal fun extractProperty(propOutput: String, propName: String): String { - val regex = Regex("\\[$propName]: \\[(.+?)]") - return regex.find(propOutput)?.groupValues?.get(1) ?: "Unknown" - } - - internal fun formatAppInfo(rawInfo: String, packageName: String): String { - // Extract useful information using regex patterns - val versionName = extractPattern(rawInfo, "versionName=(\\S+)") - val versionCode = extractPattern(rawInfo, "versionCode=(\\d+)") - val firstInstallTime = extractPattern(rawInfo, "firstInstallTime=(\\S+)") - val lastUpdateTime = extractPattern(rawInfo, "lastUpdateTime=(\\S+)") - val targetSdk = extractPattern(rawInfo, "targetSdk=(\\d+)") - val minSdk = extractPattern(rawInfo, "minSdk=(\\d+)") - - // Extract permissions - val permissions = Regex("grantedPermissions:(.*?)(?=\\n\\n)", RegexOption.DOT_MATCHES_ALL) - .find(rawInfo)?.groupValues?.get(1) - ?.split("\n") - ?.filter { it.contains("permission.") } - ?.map { it.trim() } - ?.take(10) // Limit to first 10 permissions - ?.joinToString("\n - ") ?: "None" - - return """ - |App Information for $packageName: - |------------------------- - |Version: $versionName (Code: $versionCode) - |SDK: Target=$targetSdk, Minimum=$minSdk - |Installation: - | - First Installed: $firstInstallTime - | - Last Updated: $lastUpdateTime - | - |Key Permissions (first 10): - | - $permissions - |${if (permissions != "None") "\n[Additional permissions omitted for brevity]" else ""} - """.trimMargin() - } - - internal fun extractPattern(text: String, pattern: String): String { - return Regex(pattern).find(text)?.groupValues?.get(1) ?: "Unknown" - } - - private fun registerIOSAvailableDeviceTool(server: Server) { - server.addTool( - name = "ios_available_device", - description = "Returns detailed information about the first available iOS device or simulator. Includes device ID, name, type, state (Booted/Shutdown), iOS version, and model. Prioritizes booted simulators over shutdown ones.", - inputSchema = Tool.Input() - ) { - try { - val device = runWithTimeout { - ios.getFirstAvailableDevice() - } - - val deviceInfo = """ - |iOS Device found: - |ID: ${device.id} - |Name: ${device.name} - |Type: ${device.type} - |State: ${device.state} - |OS Version: ${device.osVersion ?: "Unknown"} - |Model: ${device.modelName ?: "Unknown"} - """.trimMargin() - - CallToolResult( - content = listOf(TextContent(deviceInfo)) - ) - } catch (e: Exception) { - handleToolError(e, "Error finding available iOS device") - } - } - } - - private fun registerIOSListAppsTool(server: Server) { - server.addTool( - name = "ios_list_apps", - description = "Returns a complete list of all applications installed on the iOS device or simulator. Returns bundle IDs (e.g., com.apple.mobilesafari) for all installed apps. Device must be booted.", - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout { - ios.listApps() - } - - val formattedResult = if (result.isEmpty()) { - "No apps found on the iOS device" - } else { - "Found these apps: ${result.joinToString(", ")}" - } - - CallToolResult( - content = listOf(TextContent(formattedResult)) - ) - } catch (e: Exception) { - handleToolError(e, "Error listing iOS apps") - } - } - } - - private fun registerIOSInfoAppTool(server: Server) { - server.addTool( - name = "ios_info_app", - description = "Returns detailed information about a specific iOS application. Requires 'bundleId' parameter (e.g., com.apple.mobilesafari). Returns bundle ID and app container path. Device must be booted.", - inputSchema = Tool.Input( - required = listOf("bundleId") - ) - ) { request: CallToolRequest -> - try { - val bundleId = request.arguments["bundleId"]?.jsonPrimitive?.content - ?: return@addTool CallToolResult( - content = listOf(TextContent(BUNDLE_ID_REQUIRED)) - ) - - val rawResult = runWithTimeout { - ios.getAppInfo(bundleId) - } - - val formattedInfo = "App Information for $bundleId:\n$rawResult" - - CallToolResult( - content = listOf(TextContent(formattedInfo)) - ) - } catch (e: Exception) { - handleToolError(e, "Error retrieving iOS app information") - } - } - } - - private fun registerIOSLaunchAppTool(server: Server) { - server.addTool( - name = "ios_launch_app", - description = "Launches an iOS application on the device or simulator. Requires 'bundleId' parameter (e.g., com.apple.mobilesafari). Device must be booted before launching apps.", - inputSchema = Tool.Input( - required = listOf("bundleId") - ) - ) { request: CallToolRequest -> - try { - val bundleId = request.arguments["bundleId"]?.jsonPrimitive?.content - ?: return@addTool CallToolResult( - content = listOf(TextContent(BUNDLE_ID_REQUIRED)) - ) - - val result = runWithTimeout { - ios.launchApp(bundleId) - } - - val message = if (result) { - "Successfully launched the iOS app: $bundleId" - } else { - "Failed to launch the iOS app: $bundleId" - } - - CallToolResult( - content = listOf(TextContent(message)) - ) - } catch (e: Exception) { - handleToolError(e, "Error launching iOS app") - } - } - } - - private fun registerInstallAutomationServerTool(server: Server) { - server.addTool( - name = "install_automation_server", - description = "Installs the automation server APKs on the connected Android device. Run this once before using start_automation_server.", - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout { - // Check if device is available - val device = android.getFirstAvailableDevice() - logger.info("Installing automation server on device: ${device.id}") - - // Find the APK file - val apkPath = findAutomationServerApk() - if (apkPath == null) { - return@runWithTimeout "Automation server APK not found. Re-run install.sh to download APKs, or set VISION_TEST_APK_PATH environment variable. To build from source: ./gradlew :automation-server:assembleDebug :automation-server:assembleDebugAndroidTest" - } - - // Install the APKs - val androidDevice = android as? Android - ?: return@runWithTimeout "Android device configuration not available" - - // Install main APK - val resolvedMainApk = resolveMainApkPath(apkPath) - if (resolvedMainApk != null) { - androidDevice.executeAdb("install", "-r", resolvedMainApk) - logger.info("Installed main APK: $resolvedMainApk") - } else { - return@runWithTimeout "Main APK not found at the expected path derived from test APK: $apkPath. Ensure the main automation-server APK is built/installed (e.g., via :automation-server:assembleDebug), or re-run install.sh or set VISION_TEST_APK_PATH." - } - - // Install test APK - androidDevice.executeAdb("install", "-r", apkPath) - logger.info("Installed test APK: $apkPath") - - "Automation server APKs installed successfully on device ${device.id}. Use 'start_automation_server' to start the server." - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error installing automation server") - } - } - } - - private fun registerStartAutomationServerTool(server: Server) { - server.addTool( - name = "start_automation_server", - description = "Starts the automation server on the connected Android device. The APKs must be installed first using install_automation_server. Sets up port forwarding and starts the instrumentation server.", - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout(30000) { - val device = android.getFirstAvailableDevice() - val androidDevice = android as? Android - ?: return@runWithTimeout "Android device configuration not available" - - val port = AutomationConfig.DEFAULT_PORT - - // Check if already running - if (automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is already running on localhost:$port" - } - - // Set up port forwarding - androidDevice.executeAdb("forward", "tcp:$port", "tcp:$port") - logger.info("Port forwarding set up: tcp:$port -> tcp:$port") - - // Start instrumentation in background using nohup - // We use shell to run in background so it doesn't block - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - val command = listOf( - "adb", "-s", device.id, "shell", - "am", "instrument", "-w", - "-e", "port", port.toString(), - "-e", "class", AutomationConfig.AUTOMATION_SERVER_TEST_CLASS, - "${AutomationConfig.AUTOMATION_SERVER_TEST_PACKAGE}/${AutomationConfig.INSTRUMENTATION_RUNNER}" - ) - logger.info("Starting instrumentation: ${command.joinToString(" ")}") - - ProcessBuilder(command) - .redirectErrorStream(true) - .start() - } - - // Wait for server to start and verify via health check - var attempts = 0 - val maxAttempts = 10 - while (attempts < maxAttempts) { - kotlinx.coroutines.delay(500) - if (automationClient.isServerRunning()) { - logger.info("Automation server started successfully") - return@runWithTimeout "Automation server started successfully on device ${device.id}. Server is listening on localhost:$port" - } - attempts++ - logger.debug("Waiting for server to start... attempt $attempts/$maxAttempts") - } - - "Automation server may not have started properly. Check device logs with: adb logcat | grep AutomationServer" - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error starting automation server") - } - } - } - - private fun registerAutomationServerStatusTool(server: Server) { - server.addTool( - name = "automation_server_status", - description = "Checks if the automation server is running on the connected Android device. Returns server status and connection information.", - inputSchema = Tool.Input() - ) { - try { - val isRunning = runWithTimeout { - automationClient.isServerRunning() - } - - val statusMessage = if (isRunning) { - "Automation server is running and accessible at localhost:${AutomationConfig.DEFAULT_PORT}" - } else { - "Automation server is not running. Use 'start_automation_server' to start it." - } - - CallToolResult( - content = listOf(TextContent(statusMessage)) - ) - } catch (e: Exception) { - handleToolError(e, "Error checking automation server status") - } - } - } - - private fun registerGetUiHierarchyTool(server: Server) { - server.addTool( - name = "get_ui_hierarchy", - description = """ - Gets the COMPLETE UI hierarchy as XML from the current screen. - The automation server must be running first (use start_automation_server). - - PREFER 'get_interactive_elements' for most tasks - it returns a cleaner, - filtered list of elements you can actually interact with. - - USE THIS TOOL WHEN YOU NEED: - - Full XML structure with parent-child relationships - - Debug why an element isn't found by get_interactive_elements - - Analyze layout containers and view hierarchy - - Find elements with unusual/custom class names - - Inspect raw accessibility properties - - RETURNS: Verbose XML with ALL elements including: - - Layout containers (FrameLayout, LinearLayout, etc.) - - Invisible or disabled elements - - Every node in the accessibility tree - - TIP: Start with get_interactive_elements. Only use this if you need - the full structure or can't find what you're looking for. - """.trimIndent(), - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout(30000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - automationClient.getUiHierarchy() - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error getting UI hierarchy") - } - } - } - - private fun registerFindElementTool(server: Server) { - server.addTool( - name = "find_element", - description = """ - Finds a UI element on the current screen of the connected Android device. - Returns element info including bounds, text, and properties if found. - The automation server must be running first (use start_automation_server). - - Provide at least ONE of these parameters: - - text: Exact text match - - textContains: Partial text match - - resourceId: Resource ID (e.g., "com.example:id/button") - - className: Class name (e.g., "android.widget.Button") - - contentDescription: Accessibility content description - - FLUTTER APP TIP: Flutter apps expose text labels via 'contentDescription' instead of 'text'. - If you cannot find an element by 'text', retry using 'contentDescription' with the same value. - """.trimIndent(), - inputSchema = Tool.Input() - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(30000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - val args = request.arguments - val text = args["text"]?.jsonPrimitive?.content - val textContains = args["textContains"]?.jsonPrimitive?.content - val resourceId = args["resourceId"]?.jsonPrimitive?.content - val className = args["className"]?.jsonPrimitive?.content - val contentDescription = args["contentDescription"]?.jsonPrimitive?.content - - if (text == null && textContains == null && resourceId == null && - className == null && contentDescription == null) { - return@runWithTimeout "Error: At least one selector required (text, textContains, resourceId, className, or contentDescription)" - } - - automationClient.findElement( - text = text, - textContains = textContains, - resourceId = resourceId, - className = className, - contentDescription = contentDescription - ) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error finding element") - } - } - } - - private fun registerAndroidTapByCoordinatesTool(server: Server) { - server.addTool( - name = "android_tap_by_coordinates", - description = """ - Tap on the Android device screen at the specified (x, y) coordinates. - The automation server must be running first (use start_automation_server). - - WORKFLOW: Prefer calling 'get_interactive_elements' first to locate tappable elements. - Each interactive element includes ready-to-use 'centerX' and 'centerY' fields that you can pass - directly as the 'x' and 'y' parameters to this tool. - If you instead use 'get_ui_hierarchy' or 'find_element', elements expose bounds in the format - [left,top][right,bottom] (e.g., [100,200][300,400]). In that case, manually calculate center - coordinates: x = (left + right) / 2, y = (top + bottom) / 2. - Example (manual calculation): For bounds [100,200][300,400], tap at x=200, y=300. - - USE CASES: - - Tap buttons, links, or interactive elements after locating them - - Tap at specific screen positions for gestures or navigation - - TIP: If you know the element's text or resourceId, use 'find_element' first - to get precise bounds rather than guessing coordinates. - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("x", "y") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - val xElement = request.arguments["x"] - ?: return@runWithTimeout "Error: Missing 'x' parameter" - val x = xElement.jsonPrimitive.content.toIntOrNull() - ?: return@runWithTimeout "Error: 'x' must be an integer" - val yElement = request.arguments["y"] - ?: return@runWithTimeout "Error: Missing 'y' parameter" - val y = yElement.jsonPrimitive.content.toIntOrNull() - ?: return@runWithTimeout "Error: 'y' must be an integer" - - automationClient.tapByCoordinates(x, y) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error performing tap by coordinates") - } - } - } - - private fun registerAndroidSwipe(server: Server) { - server.addTool( - name = "android_swipe", - description = """ - Swipe on the Android device screen from one point to another. - The automation server must be running first (use start_automation_server). - - PARAMETERS: - - startX, startY: Starting coordinates of the swipe - - endX, endY: Ending coordinates of the swipe - - steps (optional, default 20): Number of steps in the swipe gesture. - Controls speed and smoothness: lower = faster (10 for quick), higher = slower (50-100 for scrolling). - - USE CASES: - - Scroll lists: swipe from bottom to top (e.g., startY=1500, endY=500) - - Navigate carousels: swipe horizontally - - Pull-to-refresh: swipe from top to bottom - - EXAMPLE: To scroll down on a 1080x1920 screen, swipe from (540, 1400) to (540, 600). - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("startX", "startY", "endX", "endY") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - val startXElement = request.arguments["startX"] - ?: return@runWithTimeout "Error: Missing 'startX' parameter" - val startX = startXElement.jsonPrimitive.content.toIntOrNull() - ?: return@runWithTimeout "Error: 'startX' must be an integer" - val startYElement = request.arguments["startY"] - ?: return@runWithTimeout "Error: Missing 'startY' parameter" - val startY = startYElement.jsonPrimitive.content.toIntOrNull() - ?: return@runWithTimeout "Error: 'startY' must be an integer" - val endXElement = request.arguments["endX"] - ?: return@runWithTimeout "Error: Missing 'endX' parameter" - val endX = endXElement.jsonPrimitive.content.toIntOrNull() - ?: return@runWithTimeout "Error: 'endX' must be an integer" - val endYElement = request.arguments["endY"] - ?: return@runWithTimeout "Error: Missing 'endY' parameter" - val endY = endYElement.jsonPrimitive.content.toIntOrNull() - ?: return@runWithTimeout "Error: 'endY' must be an integer" - val steps = request.arguments["steps"]?.jsonPrimitive?.content?.toIntOrNull() ?: 20 - - automationClient.swipe(startX, startY, endX, endY, steps) - } - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error performing swipe") - } - } - } - - private fun registerAndroidSwipeDirection(server: Server) { - server.addTool( - name = "android_swipe_direction", - description = """ - Swipe in a direction on the Android device screen. - The automation server must be running first (use start_automation_server). - - SIMPLER than 'android_swipe' - no need to calculate coordinates! - Automatically uses screen center and calculates start/end points. - - PARAMETERS: - - direction (required): "up", "down", "left", "right" - - distance (optional): "short" (20%), "medium" (40%, default), "long" (60%) - - speed (optional): "slow", "normal" (default), "fast" - - DIRECTION BEHAVIOR: - - "up" → Finger moves up, content scrolls DOWN (see more below) - - "down" → Finger moves down, content scrolls UP (see more above) - - "left" → Finger moves left (next item in carousel) - - "right" → Finger moves right (previous item / go back) - - EXAMPLES: - - Scroll a list down: direction="up" - - Scroll a list up: direction="down" - - Next carousel item: direction="left" - - Pull to refresh: direction="down", distance="long", speed="slow" - - USE 'android_swipe' instead when you need: - - Precise start/end coordinates - - Swipe from a specific element - - Diagonal swipes - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("direction") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - val direction = request.arguments["direction"]?.jsonPrimitive?.content - ?: return@runWithTimeout "Error: Missing 'direction' parameter" - - val validDirections = listOf("up", "down", "left", "right") - if (direction.lowercase() !in validDirections) { - return@runWithTimeout "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" - } - - val distance = request.arguments["distance"]?.jsonPrimitive?.content ?: "medium" - val speed = request.arguments["speed"]?.jsonPrimitive?.content ?: "normal" - - automationClient.swipeByDirection(direction, distance, speed) - } - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error performing swipe by direction") - } - } - } - - private fun registerAndroidSwipeOnElement(server: Server) { - server.addTool( - name = "android_swipe_on_element", - description = """ - Swipe on a specific UI element in a direction. - The automation server must be running first (use start_automation_server). - - PERFECT FOR: - - Carousels and horizontal scrollers - - ViewPagers and tab swiping - - Sliders and seek bars - - Any scrollable element that isn't full-screen - - PARAMETERS: - - direction (required): "up", "down", "left", "right" - - At least ONE selector (to find the element): - - resourceId: e.g., "com.example:id/carousel" - - text: Exact text match - - textContains: Partial text match - - className: e.g., "androidx.recyclerview.widget.RecyclerView" - - contentDescription: Accessibility label - - speed (optional): "slow", "normal" (default), "fast" - - HOW IT WORKS: - 1. Finds the element using the provided selector - 2. Calculates swipe coordinates within the element's bounds - 3. Performs the swipe (70% of element dimension) - - EXAMPLES: - - Carousel next: resourceId="carousel", direction="left" - - Carousel previous: resourceId="carousel", direction="right" - - Scroll list in container: className="RecyclerView", direction="up" - - USE 'android_swipe_direction' for full-screen scrolling. - USE 'android_swipe' for precise coordinate control. - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("direction") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - val direction = request.arguments["direction"]?.jsonPrimitive?.content - ?: return@runWithTimeout "Error: Missing 'direction' parameter" - - val validDirections = listOf("up", "down", "left", "right") - if (direction.lowercase() !in validDirections) { - return@runWithTimeout "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" - } - - val text = request.arguments["text"]?.jsonPrimitive?.content - val textContains = request.arguments["textContains"]?.jsonPrimitive?.content - val resourceId = request.arguments["resourceId"]?.jsonPrimitive?.content - val className = request.arguments["className"]?.jsonPrimitive?.content - val contentDescription = request.arguments["contentDescription"]?.jsonPrimitive?.content - - if (text == null && textContains == null && resourceId == null && - className == null && contentDescription == null) { - return@runWithTimeout "Error: At least one selector required (text, textContains, resourceId, className, or contentDescription)" - } - - val speed = request.arguments["speed"]?.jsonPrimitive?.content ?: "normal" - - automationClient.swipeOnElement( - direction = direction, - text = text, - textContains = textContains, - resourceId = resourceId, - className = className, - contentDescription = contentDescription, - speed = speed - ) - } - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error performing swipe on element") - } - } - } - - private fun registerAndroidPressBackTool(server: Server) { - server.addTool( - name = "android_press_back", - description = """ - Press the hardware back button on the Android device. - The automation server must be running first (use start_automation_server). - - USE CASES: - - Navigate to the previous screen in an app - - Dismiss dialogs, popups, or bottom sheets - - Close the on-screen keyboard - - Exit full-screen or immersive modes - - Cancel ongoing operations (e.g., close a search bar) - - BEHAVIOR: - - Equivalent to pressing the physical/virtual back button - - Apps may intercept this action for custom behavior - - Multiple presses may be needed to fully exit nested screens - - TIP: Use 'get_ui_hierarchy' after pressing back to verify the expected - screen is now displayed before proceeding with further actions. - """.trimIndent(), - inputSchema = Tool.Input() - ) { _: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - automationClient.pressBack() - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error pressing back") - } - } - } - - private fun registerAndroidPressHomeTool(server: Server) { - server.addTool( - name = "android_press_home", - description = """ - Press the hardware home button on the Android device. - The automation server must be running first (use start_automation_server). - - USE CASES: - - Return to the device home screen from any app - - Minimize the current app without closing it - - Exit immersive or full-screen modes - - Reset navigation state to a known starting point - - Switch context before launching a different app - - BEHAVIOR: - - Equivalent to pressing the physical/virtual home button - - The current app moves to the background (not terminated) - - Always navigates to the launcher/home screen - - Works regardless of app state or navigation depth - - TIP: Use 'get_ui_hierarchy' after pressing home to confirm you're on the - home screen, then use 'launch_app_android' to start a different app. - """.trimIndent(), - inputSchema = Tool.Input() - ) { _: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - automationClient.pressHome() - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error pressing home") - } - } - } - - private fun registerAndroidInputTextTool(server: Server) { - server.addTool( - name = "android_input_text", - description = """ - Types text into the currently focused element on the Android device. - The automation server must be running first (use start_automation_server). - - WORKFLOW: First tap on a text field using 'android_tap_by_coordinates' to focus it, - then call this tool to type text into it. - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("text") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - val text = request.arguments["text"]?.jsonPrimitive?.content - ?: return@runWithTimeout "Error: Missing 'text' parameter" - - automationClient.inputText(text) - } + private val discovery = ToolDiscovery(logger) - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error typing text on Android") - } - } - } - - private fun registerAndroidGetDeviceInfoTool(server: Server) { - server.addTool( - name = "android_get_device_info", - description = """ - Gets device information from the connected Android device via the automation server. - The automation server must be running first (use start_automation_server). - - RETURNS: - - Display size (width x height in pixels) - - Display rotation (0, 90, 180, or 270 degrees) - - SDK version (Android API level) - - USE CASES: - - Determine screen dimensions for calculating tap/swipe coordinates - - Check device orientation before performing gestures - - Verify SDK version for feature compatibility - """.trimIndent(), - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - automationClient.getDeviceInfo() - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error getting device info") - } - } - } - - private fun registerGetInteractiveElementsTool(server: Server) { - server.addTool( - name = "get_interactive_elements", - description = """ - Gets a filtered list of interactive UI elements from the current screen. - The automation server must be running first (use start_automation_server). - - MUCH MORE USEFUL than 'get_ui_hierarchy' for most tasks because it: - - Returns only elements you can actually interact with - - Filters out layout containers and invisible elements - - Provides center coordinates ready for tapping - - Returns clean JSON instead of verbose XML - - HEURISTICS USED (handles missing accessibility properties): - - Explicitly interactive: clickable, checkable, scrollable, long-clickable - - Known interactive classes: Button, EditText, CheckBox, Switch, etc. - - Has meaningful content: text, content-description, or resource-id - - Excludes: layout containers, invisible elements, disabled elements - - OPTIONAL PARAMETERS: - - includeDisabled: Set to true to also include disabled elements (default: false) - - RETURNS for each element: - - text, contentDescription, resourceId, className - - bounds (e.g., "[100,200][300,400]") - - centerX, centerY (ready for android_tap_by_coordinates) - - isClickable, isCheckable, isScrollable, isLongClickable, isEnabled - - WORKFLOW: - 1. Call get_interactive_elements to see what you can interact with - 2. Find the element you want by text, contentDescription, or resourceId - 3. Use centerX, centerY with android_tap_by_coordinates to tap it - """.trimIndent(), - inputSchema = Tool.Input() - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(30000) { - if (!automationClient.isServerRunning()) { - return@runWithTimeout "Automation server is not running. Use 'start_automation_server' first." - } - - val includeDisabledRaw = request.arguments["includeDisabled"] - ?.jsonPrimitive?.content - val includeDisabled = when (includeDisabledRaw) { - null -> false - "true" -> true - "false" -> false - else -> return@runWithTimeout "Invalid value for 'includeDisabled': '$includeDisabledRaw'. Must be true or false." - } - - automationClient.getInteractiveElements(includeDisabled) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error getting interactive elements") - } - } - } - - // ==================== iOS Automation Tools ==================== - - /** Track the xcodebuild process so we can kill it to stop the server */ - @Volatile - private var iosXcodebuildProcess: Process? = null - - /** - * Builds the xcodebuild command for launching the iOS automation server. - * Returns the command list for either pre-built bundle or source build path. - */ - internal fun buildXcodebuildCommand( - xctestrunPath: String?, - projectPath: String?, - simulatorName: String - ): List { - val testId = "${IOSAutomationConfig.UI_TEST_TARGET}/${IOSAutomationConfig.TEST_CLASS}/${IOSAutomationConfig.TEST_METHOD}" - return if (xctestrunPath != null) { - listOf( - "xcodebuild", "test-without-building", - "-xctestrun", xctestrunPath, - "-destination", "platform=iOS Simulator,name=$simulatorName", - "-only-testing:$testId" - ) - } else { - val resolvedProject = requireNotNull(projectPath) { - "projectPath must be set when xctestrunPath is null" - } - listOf( - "xcodebuild", "test", - "-project", resolvedProject, - "-scheme", IOSAutomationConfig.XCODE_SCHEME, - "-destination", "platform=iOS Simulator,name=$simulatorName", - "-only-testing:$testId" - ) - } - } - - /** - * Result of polling for the iOS automation server to start. - * [message] is the user-facing result. [earlyExitCode] is non-null when - * the xcodebuild process exited before the server came up (caller may fallback). - */ - private data class ServerPollResult( - val message: String, - val earlyExitCode: Int? = null + private val registrars: List = listOf( + AndroidDeviceToolRegistrar(android), + AndroidAutomationToolRegistrar(android, automationClient, discovery), + IOSDeviceToolRegistrar(ios), + IOSAutomationToolRegistrar(ios, iosAutomationClient, discovery, logger) ) - /** Formats a command list as a shell-safe string, quoting arguments that contain spaces or special characters. */ - private fun shellQuote(command: List): String { - return command.joinToString(" ") { arg -> - if (arg.any { it.isWhitespace() || it in setOf('(', ')', '&', '|', ';', '*', '?', '<', '>', '$', '!', '`', '"') }) { - "'${arg.replace("'", "'\\''")}'" - } else { - arg - } - } - } - - /** - * Starts an xcodebuild process and polls until the server responds or the process exits. - * Returns a [ServerPollResult] with [earlyExitCode] set when the process exits early. - */ - private suspend fun startAndPollServer( - command: List, - maxAttempts: Int, - port: Int, - label: String - ): ServerPollResult { - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - logger.info("Starting iOS automation server ($label): ${command.joinToString(" ")}") - val process = ProcessBuilder(command) - .redirectErrorStream(true) - .redirectOutput(ProcessBuilder.Redirect.DISCARD) - .start() - iosXcodebuildProcess = process - } - - var attempts = 0 - while (attempts < maxAttempts) { - kotlinx.coroutines.delay(2000) - - val process = iosXcodebuildProcess - if (process != null && !process.isAlive) { - val exitCode = process.exitValue() - logger.warn("xcodebuild ($label) exited early with code $exitCode") - iosXcodebuildProcess = null - return ServerPollResult( - message = "xcodebuild ($label) exited with code $exitCode before the server started. " + - "Run manually to see errors:\n${shellQuote(command)}", - earlyExitCode = exitCode - ) - } - - if (iosAutomationClient.isServerRunning()) { - logger.info("iOS automation server started successfully ($label)") - return ServerPollResult("iOS automation server started successfully ($label). Server is listening on localhost:$port") - } - attempts++ - logger.debug("Waiting for iOS server to start ($label)... attempt $attempts/$maxAttempts") - } - - return ServerPollResult("iOS automation server did not respond after ${maxAttempts * 2}s. xcodebuild may still be building. Check with 'ios_automation_server_status' or run xcodebuild manually to see output.") - } - - private fun registerIOSStartAutomationServerTool(server: Server) { - server.addTool( - name = "ios_start_automation_server", - description = """ - Starts the iOS automation server on the booted iOS simulator. - Uses pre-built test bundle if available, otherwise builds from source. - - The server starts on port ${IOSAutomationConfig.DEFAULT_PORT} and is directly - accessible at localhost (no port forwarding needed for iOS simulators). - """.trimIndent(), - inputSchema = Tool.Input() - ) { - try { - // Timeout accounts for worst case: pre-built attempt (60s) + source fallback (120s) - val result = runWithTimeout(200000) { - val port = IOSAutomationConfig.DEFAULT_PORT - - // Check if already running - if (iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is already running on localhost:$port" - } - - // Clean up any orphaned previous process - iosXcodebuildProcess?.let { process -> - if (process.isAlive) { - logger.info("Destroying orphaned xcodebuild process before starting a new one") - process.destroyForcibly() - } - iosXcodebuildProcess = null - } - - // Discover launch path: pre-built bundle preferred, source build as fallback - val xctestrunPath = findXctestrun() - val projectPath = findXcodeProject() - - if (xctestrunPath == null && projectPath == null) { - return@runWithTimeout "Neither pre-built iOS test bundle nor Xcode source project found. " + - "To fix: re-run install.sh on macOS to download the pre-built bundle, " + - "or clone the VisionTest repository and set ${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV} " + - "to build from source." - } - - val usingPrebuilt = xctestrunPath != null - if (usingPrebuilt) { - logger.info("Using pre-built iOS test bundle: $xctestrunPath") - } else { - logger.info("Using source build from Xcode project: $projectPath") - } - - // Get the booted simulator - val device = ios.getFirstAvailableDevice() - val simulatorName = device.name - - // Pre-built path starts faster (no compilation), use shorter timeout - val command = buildXcodebuildCommand(xctestrunPath, projectPath, simulatorName) - val maxAttempts = if (usingPrebuilt) 30 else 60 - - val label = if (usingPrebuilt) "pre-built bundle" else "source build" - val primaryResult = startAndPollServer(command, maxAttempts, port, label) - - if (primaryResult.earlyExitCode == null) { - return@runWithTimeout primaryResult.message - } - - // Primary attempt exited early — try source build fallback if available - if (usingPrebuilt && projectPath != null) { - logger.warn("Pre-built bundle failed (exit code ${primaryResult.earlyExitCode}), falling back to source build") - val fallbackCommand = buildXcodebuildCommand(null, projectPath, simulatorName) - val fallbackResult = startAndPollServer(fallbackCommand, 60, port, "source build fallback") - return@runWithTimeout fallbackResult.message - } - - primaryResult.message - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error starting iOS automation server") - } - } - } - - private fun registerIOSAutomationServerStatusTool(server: Server) { - server.addTool( - name = "ios_automation_server_status", - description = "Checks if the iOS automation server is running on the simulator. Returns server status and connection information.", - inputSchema = Tool.Input() - ) { - try { - val isRunning = runWithTimeout { - iosAutomationClient.isServerRunning() - } - - val statusMessage = if (isRunning) { - "iOS automation server is running and accessible at localhost:${IOSAutomationConfig.DEFAULT_PORT}" - } else { - "iOS automation server is not running. Use 'ios_start_automation_server' to start it." - } - - CallToolResult( - content = listOf(TextContent(statusMessage)) - ) - } catch (e: Exception) { - handleToolError(e, "Error checking iOS automation server status") - } - } - } - - private fun registerIOSGetUiHierarchyTool(server: Server) { - server.addTool( - name = "ios_get_ui_hierarchy", - description = """ - Gets the COMPLETE UI hierarchy as XML from the current iOS simulator screen. - The iOS automation server must be running first (use ios_start_automation_server). - - PREFER 'ios_get_interactive_elements' for most tasks - it returns a cleaner, - filtered list of elements you can actually interact with. - - USE THIS TOOL WHEN YOU NEED: - - Full XML structure with parent-child relationships - - Debug why an element isn't found by ios_get_interactive_elements - - Analyze layout structure - - Inspect raw accessibility properties - - OPTIONAL PARAMETERS: - - bundleId: Bundle ID of the app to query (e.g., "com.apple.Preferences"). - If not provided, queries springboard (which only shows system UI, not app content). - ALWAYS provide bundleId when inspecting an app's UI. - """.trimIndent(), - inputSchema = Tool.Input() - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(30000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - val bundleId = request.arguments["bundleId"]?.jsonPrimitive?.content - iosAutomationClient.getUiHierarchy(bundleId) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error getting iOS UI hierarchy") - } - } - } - - private fun registerIOSGetInteractiveElementsTool(server: Server) { - server.addTool( - name = "ios_get_interactive_elements", - description = """ - Gets a filtered list of interactive UI elements from the current iOS simulator screen. - The iOS automation server must be running first (use ios_start_automation_server). - - Returns only elements you can interact with (buttons, text fields, switches, etc.) - with center coordinates ready for tapping via ios_tap_by_coordinates. - - OPTIONAL PARAMETERS: - - includeDisabled: Set to true to include disabled elements (default: false) - - bundleId: Bundle ID of the app to query (e.g., "com.apple.Preferences"). - If not provided, queries springboard (which only shows system UI, not app content). - ALWAYS provide bundleId when inspecting an app's UI. - - WORKFLOW: - 1. Call ios_get_interactive_elements with bundleId to see what you can interact with - 2. Find the element by text, label, or identifier - 3. Use centerX, centerY with ios_tap_by_coordinates to tap it - """.trimIndent(), - inputSchema = Tool.Input() - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(30000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - - val includeDisabledRaw = request.arguments["includeDisabled"] - ?.jsonPrimitive?.content - val includeDisabled = when (includeDisabledRaw) { - null -> false - "true" -> true - "false" -> false - else -> return@runWithTimeout "Invalid value for 'includeDisabled': '$includeDisabledRaw'. Must be true or false." - } - val bundleId = request.arguments["bundleId"]?.jsonPrimitive?.content - - iosAutomationClient.getInteractiveElements(includeDisabled, bundleId) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error getting iOS interactive elements") - } - } - } - - private fun registerIOSTapByCoordinatesTool(server: Server) { - server.addTool( - name = "ios_tap_by_coordinates", - description = """ - Tap on the iOS simulator screen at the specified (x, y) coordinates. - The iOS automation server must be running first (use ios_start_automation_server). - - WORKFLOW: First call 'ios_get_interactive_elements' to locate the target element. - Use the centerX, centerY values from the returned elements. - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("x", "y") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - - val x = request.arguments["x"]?.jsonPrimitive?.content?.toIntOrNull() - ?: return@runWithTimeout "Error: Missing 'x' parameter" - val y = request.arguments["y"]?.jsonPrimitive?.content?.toIntOrNull() - ?: return@runWithTimeout "Error: Missing 'y' parameter" - - iosAutomationClient.tapByCoordinates(x, y) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error performing iOS tap") - } - } - } - - private fun registerIOSSwipeTool(server: Server) { - server.addTool( - name = "ios_swipe", - description = """ - Swipe on the iOS simulator screen from one point to another. - The iOS automation server must be running first (use ios_start_automation_server). - - PARAMETERS: - - startX, startY: Starting coordinates - - endX, endY: Ending coordinates - - steps (optional, default 20): Controls speed (maps to duration: steps * 0.05 seconds) - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("startX", "startY", "endX", "endY") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - - val startX = request.arguments["startX"]?.jsonPrimitive?.content?.toIntOrNull() - ?: return@runWithTimeout "Error: Missing 'startX' parameter" - val startY = request.arguments["startY"]?.jsonPrimitive?.content?.toIntOrNull() - ?: return@runWithTimeout "Error: Missing 'startY' parameter" - val endX = request.arguments["endX"]?.jsonPrimitive?.content?.toIntOrNull() - ?: return@runWithTimeout "Error: Missing 'endX' parameter" - val endY = request.arguments["endY"]?.jsonPrimitive?.content?.toIntOrNull() - ?: return@runWithTimeout "Error: Missing 'endY' parameter" - val steps = request.arguments["steps"]?.jsonPrimitive?.content?.toIntOrNull() ?: 20 - - iosAutomationClient.swipe(startX, startY, endX, endY, steps) - } - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error performing iOS swipe") - } - } - } - - private fun registerIOSSwipeDirectionTool(server: Server) { - server.addTool( - name = "ios_swipe_direction", - description = """ - Swipe in a direction on the iOS simulator screen. - The iOS automation server must be running first (use ios_start_automation_server). - - SIMPLER than 'ios_swipe' - no need to calculate coordinates! - - PARAMETERS: - - direction (required): "up", "down", "left", "right" - - distance (optional): "short" (20%), "medium" (40%, default), "long" (60%) - - speed (optional): "slow", "normal" (default), "fast" - - DIRECTION BEHAVIOR: - - "up" → Finger moves up, content scrolls DOWN - - "down" → Finger moves down, content scrolls UP - - "left" → Finger moves left (next item) - - "right" → Finger moves right (previous item) - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("direction") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - - val direction = request.arguments["direction"]?.jsonPrimitive?.content - ?: return@runWithTimeout "Error: Missing 'direction' parameter" - - val validDirections = listOf("up", "down", "left", "right") - if (direction.lowercase() !in validDirections) { - return@runWithTimeout "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" - } - - val distance = request.arguments["distance"]?.jsonPrimitive?.content ?: "medium" - val speed = request.arguments["speed"]?.jsonPrimitive?.content ?: "normal" - - iosAutomationClient.swipeByDirection(direction, distance, speed) - } - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error performing iOS swipe by direction") - } - } - } - - private fun registerIOSFindElementTool(server: Server) { - server.addTool( - name = "ios_find_element", - description = """ - Finds a UI element on the current iOS simulator screen. - Returns element info including bounds, text, and properties if found. - The iOS automation server must be running first (use ios_start_automation_server). - - Provide at least ONE of these parameters: - - text: Exact text match - - textContains: Partial text match - - resourceId: Accessibility identifier - - className: Element type name (e.g., "Button", "TextField") - - contentDescription: Accessibility label - - bundleId: Bundle ID of the app to search in (e.g., "com.apple.Preferences"). - If not provided, searches springboard. ALWAYS provide bundleId when searching in an app. - """.trimIndent(), - inputSchema = Tool.Input() - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(30000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - - val args = request.arguments - val text = args["text"]?.jsonPrimitive?.content - val textContains = args["textContains"]?.jsonPrimitive?.content - val identifier = args["resourceId"]?.jsonPrimitive?.content - val elementType = args["className"]?.jsonPrimitive?.content - val label = args["contentDescription"]?.jsonPrimitive?.content - val bundleId = args["bundleId"]?.jsonPrimitive?.content - - if (text == null && textContains == null && identifier == null && - elementType == null && label == null) { - return@runWithTimeout "Error: At least one selector required (text, textContains, resourceId, className, or contentDescription)" - } - - iosAutomationClient.findElement( - text = text, - textContains = textContains, - identifier = identifier, - elementType = elementType, - label = label, - bundleId = bundleId - ) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error finding iOS element") - } - } - } - - private fun registerIOSGetDeviceInfoTool(server: Server) { - server.addTool( - name = "ios_get_device_info", - description = """ - Gets device information from the iOS simulator via the automation server. - The iOS automation server must be running first (use ios_start_automation_server). - - RETURNS: - - Display size (width x height in pixels) - - Display rotation - - iOS version - - Device model - """.trimIndent(), - inputSchema = Tool.Input() - ) { - try { - val result = runWithTimeout { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - iosAutomationClient.getDeviceInfo() - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error getting iOS device info") - } - } - } - - private fun registerIOSPressHomeTool(server: Server) { - server.addTool( - name = "ios_press_home", - description = """ - Press the home button on the iOS simulator. - The iOS automation server must be running first (use ios_start_automation_server). - - Returns to the home screen. The current app moves to the background. - """.trimIndent(), - inputSchema = Tool.Input() - ) { _: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - iosAutomationClient.pressHome() - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error pressing iOS home button") - } - } - } - - private fun registerIOSInputTextTool(server: Server) { - server.addTool( - name = "ios_input_text", - description = """ - Types text into the currently focused element on the iOS simulator. - The iOS automation server must be running first (use ios_start_automation_server). - - WORKFLOW: First tap on a text field using 'ios_tap_by_coordinates' to focus it, - then call this tool to type text into it. - - PARAMETERS: - - text (required): The text to type. - - bundleId (required for app UI): Bundle ID of the target app (e.g., "com.apple.Preferences"). - ALWAYS provide bundleId when typing into a third-party or system app. - Without bundleId, the server targets Springboard and will fail if no focused element is found. - Only omit bundleId when interacting with Springboard system UI itself. - """.trimIndent(), - inputSchema = Tool.Input( - required = listOf("text") - ) - ) { request: CallToolRequest -> - try { - val result = runWithTimeout(10000) { - if (!iosAutomationClient.isServerRunning()) { - return@runWithTimeout "iOS automation server is not running. Use 'ios_start_automation_server' first." - } - - val text = request.arguments["text"]?.jsonPrimitive?.content - ?: return@runWithTimeout "Error: Missing 'text' parameter" - val bundleId = request.arguments["bundleId"]?.jsonPrimitive?.content - - iosAutomationClient.inputText(text, bundleId) - } - - CallToolResult( - content = listOf(TextContent(result)) - ) - } catch (e: Exception) { - handleToolError(e, "Error typing text on iOS") - } - } - } - - private fun registerIOSStopAutomationServerTool(server: Server) { - server.addTool( - name = "ios_stop_automation_server", - description = "Stops the iOS automation server running on the simulator.", - inputSchema = Tool.Input() - ) { - try { - val process = iosXcodebuildProcess - if (process != null && process.isAlive) { - process.destroyForcibly() - iosXcodebuildProcess = null - CallToolResult(content = listOf(TextContent("iOS automation server stopped successfully."))) - } else { - iosXcodebuildProcess = null - CallToolResult(content = listOf(TextContent("iOS automation server is not running."))) - } - } catch (e: Exception) { - handleToolError(e, "Error stopping iOS automation server") - } - } - } - - private fun isValidXcodeProjectPath(file: File): Boolean { - return file.exists() && file.isDirectory && file.name.endsWith(".xcodeproj") - } - - private fun findXcodeProject(): String? { - // 0. Check environment variable first (allows explicit override) - System.getenv(IOSAutomationConfig.XCODE_PROJECT_PATH_ENV)?.let { envPath -> - val envFile = File(envPath) - if (isValidXcodeProjectPath(envFile)) { - logger.info("Using Xcode project from ${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV}: $envPath") - return envFile.absolutePath - } - if (envFile.exists()) { - logger.warn("${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV} path is not a valid .xcodeproj directory: $envPath") - } else { - logger.warn("${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV} set but path not found: $envPath") - } - } - - val relativePath = IOSAutomationConfig.XCODE_PROJECT_PATH - - // 1. Try relative to current working directory - val cwdFile = File(relativePath) - if (isValidXcodeProjectPath(cwdFile)) { - return cwdFile.absolutePath - } - - // 2. Try relative to project root - val projectRoot = findProjectRoot(File(".").absoluteFile) - if (projectRoot != null) { - val projectFile = File(projectRoot, relativePath) - if (isValidXcodeProjectPath(projectFile)) { - return projectFile.absolutePath - } - } - - // 3. Try relative to code source - val codeSourceRoot = findCodeSourceRoot() - if (codeSourceRoot != null) { - val codeSourceFile = File(codeSourceRoot, relativePath) - if (isValidXcodeProjectPath(codeSourceFile)) { - return codeSourceFile.absolutePath - } - } - - return null - } - - // ==================== Install Directory Resolution ==================== - - /** - * Resolves the install directory from VISIONTEST_DIR env var, JAR directory, or default. - * Shared by APK discovery and iOS xctestrun discovery. - */ - internal fun resolveInstallDir(): File { - val installDirPath = System.getenv("VISIONTEST_DIR") - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?: findJarDirectory()?.absolutePath - ?: "${System.getProperty("user.home")}/.local/share/visiontest" - return File(installDirPath) - } - - // ==================== Android APK Discovery ==================== - - internal fun findAutomationServerApk(): String? { - val cwd = File(".").absoluteFile - val codeSourceRoot = findCodeSourceRoot() - return findAutomationServerApk( - envApkPath = System.getenv("VISION_TEST_APK_PATH"), - searchRoots = listOfNotNull(cwd, codeSourceRoot, findProjectRoot(cwd)), - installDir = resolveInstallDir() - ) - } - - /** - * Given the path to a test APK, resolves the corresponding main APK path. - * - * For Gradle build output (androidTest path), derives the main APK by stripping "androidTest/" and "-androidTest". - * For install-dir APKs (e.g. automation-server-test.apk), looks for a sibling automation-server.apk. - * Returns null if no main APK can be found. - */ - internal fun resolveMainApkPath(testApkPath: String): String? { - // Derive main APK path from Gradle androidTest layout - val derivedPath = testApkPath - .replaceFirst("androidTest/", "") - .replaceFirst("-androidTest", "") - val derivedFile = File(derivedPath) - val isSamePath = derivedPath == testApkPath - val isKnownTestName = testApkPath.endsWith("automation-server-test.apk") - - if (derivedFile.exists() && !isSamePath && !isKnownTestName) { - return derivedPath - } - - // Fallback: check for simple-named APK in the same directory (install dir) - val parent = File(testApkPath).parentFile ?: return null - val siblingApk = File(parent, "automation-server.apk") - return if (siblingApk.exists()) siblingApk.absolutePath else null - } - - /** - * Returns the directory containing the running JAR, or null if not running from a JAR. - * Used to discover APKs co-located with the JAR in custom install directories. - */ - private fun findJarDirectory(): File? { - return try { - val location = this::class.java.protectionDomain?.codeSource?.location?.toURI()?.let { File(it) } - if (location != null && location.isFile && location.name.endsWith(".jar")) { - location.parentFile - } else null - } catch (e: Exception) { - logger.debug("Could not determine JAR directory: ${e.message}") - null - } - } - - internal fun findAutomationServerApk( - envApkPath: String?, - searchRoots: List, - installDir: File? = null - ): String? { - val apkRelativePath = "automation-server/build/outputs/apk/androidTest/debug/automation-server-debug-androidTest.apk" - - logger.debug("Searching for automation server APK...") - - // 1. Check environment variable first (allows explicit configuration) - envApkPath?.let { envPath -> - val file = File(envPath) - if (file.exists()) { - logger.info("Using APK from VISION_TEST_APK_PATH: $envPath") - return file.absolutePath - } - logger.warn("VISION_TEST_APK_PATH set but file not found: $envPath") - } - - // 2. Try relative to each search root (CWD, code source, project root) - for (root in searchRoots) { - val apkFile = File(root, apkRelativePath) - logger.debug("Checking path: ${apkFile.absolutePath}") - if (apkFile.exists()) { - logger.info("Found APK at: ${apkFile.absolutePath}") - return apkFile.absolutePath - } - } - - // 3. Try install directory as lowest-priority fallback - if (installDir != null) { - val installedApk = File(installDir, "automation-server-test.apk") - if (installedApk.exists()) { - logger.info("Found APK in install directory: ${installedApk.absolutePath}") - return installedApk.absolutePath - } - } - - logger.warn("APK not found in ${searchRoots.size} search roots.") - logger.warn("Re-run install.sh to download APKs, or set VISION_TEST_APK_PATH environment variable.") - return null - } - - // ==================== iOS xctestrun Discovery ==================== - - private fun findXctestrun(): String? { - return findXctestrun(resolveInstallDir()) - } - - /** - * Searches for a pre-built .xctestrun file in the install directory's - * ios-automation-server/ subdirectory. - * - * Returns the absolute path to the first .xctestrun file found (sorted alphabetically), - * or null if none exists. - */ - internal fun findXctestrun(installDir: File): String? { - val bundleDir = File(installDir, IOSAutomationConfig.XCTESTRUN_BUNDLE_DIR) - logger.debug("Searching for .xctestrun in: ${bundleDir.absolutePath}") - - if (!bundleDir.isDirectory) { - logger.debug("iOS bundle directory does not exist: ${bundleDir.absolutePath}") - return null - } - - val xctestrunFiles = bundleDir.listFiles { file -> - file.isFile && file.name.endsWith(".xctestrun") - }?.sortedBy { it.name } - - if (xctestrunFiles.isNullOrEmpty()) { - logger.debug("No .xctestrun files found in: ${bundleDir.absolutePath}") - return null - } - - if (xctestrunFiles.size > 1) { - logger.info("Multiple .xctestrun files found, using first: ${xctestrunFiles.first().name}") - } - - val result = xctestrunFiles.first().absolutePath - logger.info("Found xctestrun: $result") - return result - } - - private fun findCodeSourceRoot(): File? { - return try { - val codeSource = this::class.java.protectionDomain?.codeSource - val location = codeSource?.location?.toURI()?.let { File(it) } - - if (location != null) { - logger.debug("Code source location: ${location.absolutePath}") - - // If running from JAR (app/build/libs/visiontest.jar), go up 3 levels to project root - if (location.isFile && location.name.endsWith(".jar")) { - return location.parentFile?.parentFile?.parentFile - } - - // If running from classes dir (app/build/classes/kotlin/main), go up 5 levels - if (location.isDirectory && location.path.contains("build/classes")) { - return location.parentFile?.parentFile?.parentFile?.parentFile?.parentFile - } - - // Try going up until we find settings.gradle.kts - return findProjectRoot(location) - } - null - } catch (e: Exception) { - logger.debug("Could not determine code source location: ${e.message}") - null - } - } - - internal fun findProjectRoot(startFrom: File): File? { - var current = startFrom.absoluteFile - // Handle trailing "." in path - if (current.name == ".") { - current = current.parentFile ?: return null - } - - repeat(10) { - val settingsKts = File(current, "settings.gradle.kts") - val settingsGroovy = File(current, "settings.gradle") - logger.debug("Checking for settings.gradle in: ${current.absolutePath}") - - if (settingsKts.exists() || settingsGroovy.exists()) { - logger.debug("Found project root: ${current.absolutePath}") - return current - } - current = current.parentFile ?: return null - } - return null + fun registerAllTools(server: Server) { + val scope = ToolScope(server, logger, toolTimeoutMillis) + registrars.forEach { it.registerTools(scope) } } } diff --git a/openspec/changes/refactor-toolfactory/tasks.md b/openspec/changes/refactor-toolfactory/tasks.md index 77ff873..20aa084 100644 --- a/openspec/changes/refactor-toolfactory/tasks.md +++ b/openspec/changes/refactor-toolfactory/tasks.md @@ -26,8 +26,8 @@ ## 6. Wire Up and Replace -- [ ] 6.1 Replace `ToolFactory.kt` content with thin coordinator: constructor stays the same, creates `ToolDiscovery` and 4 registrars, `registerAllTools()` creates `ToolScope` and delegates. Remove all tool registration methods, helpers, and discovery functions. -- [ ] 6.2 Verify `Main.kt` requires zero changes — `ToolFactory(android, ios, logger).registerAllTools(server)` still compiles. +- [x] 6.1 Replace `ToolFactory.kt` content with thin coordinator: constructor stays the same, creates `ToolDiscovery` and 4 registrars, `registerAllTools()` creates `ToolScope` and delegates. Remove all tool registration methods, helpers, and discovery functions. +- [x] 6.2 Verify `Main.kt` requires zero changes — `ToolFactory(android, ios, logger).registerAllTools(server)` still compiles. ## 7. Verify and Document From 5b2401c7b43c7a998cc0959eca5c2e06a4dce1a9 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 13:06:42 +0100 Subject: [PATCH 07/10] feat: update CLAUDE.md and CONTRIBUTING.md --- CLAUDE.md | 16 ++++++++++---- CONTRIBUTING.md | 22 ++++++++++++++----- .../changes/refactor-toolfactory/tasks.md | 8 +++---- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b30ddd7..b8741b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,15 @@ VisionTest is an MCP (Model Context Protocol) server enabling AI agents to inter Kotlin/JVM server using stdio transport. Key files: - `Main.kt` - Entry point, initializes managers and connects via stdio -- `ToolFactory.kt` - Registers all MCP tools (Android, iOS, and Automation) +- `ToolFactory.kt` - Thin coordinator that wires registrars and delegates tool registration +- `tools/ToolDsl.kt` - `ToolScope` DSL wrapping timeout/error handling + `CallToolRequest` extension helpers +- `tools/ToolRegistrar.kt` - Interface for modular tool registration +- `tools/ToolHelpers.kt` - Pure utility functions (`extractProperty`, `formatAppInfo`, etc.) +- `tools/AndroidDeviceToolRegistrar.kt` - 4 Android device tools +- `tools/AndroidAutomationToolRegistrar.kt` - 14 Android automation tools +- `tools/IOSDeviceToolRegistrar.kt` - 4 iOS device tools +- `tools/IOSAutomationToolRegistrar.kt` - 12 iOS automation tools + xcodebuild process management +- `discovery/ToolDiscovery.kt` - APK, Xcode project, xctestrun, and project root discovery - `android/Android.kt` - ADB communication via Adam library - `android/AutomationClient.kt` - HTTP client for Android Automation Server JSON-RPC - `ios/IOSManager.kt` - iOS simulator operations via `xcrun simctl` @@ -364,8 +372,8 @@ All Gradle tests are pure JVM (no device/emulator needed). iOS tests run on the | `android/AndroidValidationTest.kt` | `isValidPackageName`, `validateForwardArgs`, `validateShellArgs`, `validateInstallArgs` | | `android/AutomationClientTest.kt` | `sendRequest` POST/params/errors, `isServerRunning` health check (MockWebServer) | | `config/AppConfigTest.kt` | Default config values and log level | -| `ToolFactoryHelpersTest.kt` | `extractProperty`, `extractPattern`, `formatAppInfo` | -| `ToolFactoryPathTest.kt` | `findProjectRoot` (settings.gradle discovery, depth limit, edge cases), `findAutomationServerApk` (env var, search roots, ordering) | +| `ToolFactoryHelpersTest.kt` | `ToolHelpers.extractProperty`, `extractPattern`, `formatAppInfo` | +| `ToolFactoryPathTest.kt` | `ToolDiscovery.findProjectRoot`, `findAutomationServerApk`, `resolveMainApkPath`, `findXctestrun`; `IOSAutomationToolRegistrar.buildXcodebuildCommand` | ### Automation Server (`automation-server/src/test/java/com/example/automationserver/`) @@ -392,7 +400,7 @@ See `.claude/unit-testing-strategy.md` for the full testing roadmap (Plans 1-7). - Device list caching reduces ADB overhead (validity: 1000ms default) - Retry logic with exponential backoff in `ErrorHandler.retryOperation()` - Custom exception hierarchy in `Exceptions.kt` with platform-specific error codes -- Tool timeout wrapper: `runWithTimeout()` in ToolFactory (default: 10s, 30s for UI hierarchy) +- Tool timeout wrapper: `ToolScope` DSL with `withTimeout` (default: 10s, 30s for UI hierarchy, 200s for iOS server startup) - Automation Server uses Ktor/Netty for HTTP server with Gson serialization - Template Method Pattern: `BaseUiAutomatorBridge` defines operations, subclasses provide `UiDevice`, `UiAutomation`, and display bounds - Reflection-based hierarchy dumping via `UiDevice.getWindowRoots()` for Flutter app support diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c4c8ad..b09400e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,17 @@ visiontest/ ├── app/ # MCP Server (Kotlin/JVM) │ └── src/main/kotlin/com/example/visiontest/ │ ├── Main.kt # Entry point -│ ├── ToolFactory.kt # MCP tool registration +│ ├── ToolFactory.kt # Thin coordinator wiring registrars +│ ├── tools/ +│ │ ├── ToolDsl.kt # ToolScope DSL + CallToolRequest helpers +│ │ ├── ToolRegistrar.kt # Interface for modular registration +│ │ ├── ToolHelpers.kt # Pure utility functions +│ │ ├── AndroidDeviceToolRegistrar.kt +│ │ ├── AndroidAutomationToolRegistrar.kt +│ │ ├── IOSDeviceToolRegistrar.kt +│ │ └── IOSAutomationToolRegistrar.kt +│ ├── discovery/ +│ │ └── ToolDiscovery.kt # APK, Xcode project, xctestrun discovery │ ├── android/ │ │ ├── Android.kt # ADB communication (Adam library) │ │ └── AutomationClient.kt # JSON-RPC client (Android) @@ -247,8 +257,8 @@ All Gradle tests are pure JVM unit tests (no device or emulator required). iOS t | `app/` | `AndroidValidationTest.kt` | Package name validation, ADB argument validation | | `app/` | `AutomationClientTest.kt` | `sendRequest` POST/params/errors, `isServerRunning` health check | | `app/` | `AppConfigTest.kt` | Default configuration values | -| `app/` | `ToolFactoryHelpersTest.kt` | Property extraction, pattern matching, app info formatting | -| `app/` | `ToolFactoryPathTest.kt` | `findProjectRoot`, `findAutomationServerApk`, `findXctestrun` | +| `app/` | `ToolFactoryHelpersTest.kt` | `ToolHelpers.extractProperty`, `extractPattern`, `formatAppInfo` | +| `app/` | `ToolFactoryPathTest.kt` | `ToolDiscovery.findProjectRoot`, `findAutomationServerApk`, `resolveMainApkPath`, `findXctestrun`; `IOSAutomationToolRegistrar.buildXcodebuildCommand` | | `automation-server/` | `JsonRpcModelsTest.kt` | JSON-RPC error factory methods, request/response defaults | | `automation-server/` | `UiAutomatorModelsTest.kt` | Data classes, default values, enum entries | | `automation-server/` | `ServerConfigPortTest.kt` | Port validation boundaries | @@ -300,12 +310,12 @@ curl -X POST http://localhost:9009/jsonrpc -H 'Content-Type: application/json' \ 1. Add method to `BaseUiAutomatorBridge.kt` (uses `getUiDevice()`, `getUiAutomation()`, `getDisplayRect()`) 2. Register in `JsonRpcServerInstrumented.kt` `executeMethod()` 3. Add client method to `AutomationClient.kt` -4. Create MCP tool in `ToolFactory.kt` +4. Add the MCP tool to the appropriate registrar in `tools/` (e.g., `AndroidAutomationToolRegistrar.kt`) ### Adding New MCP Tools -1. Create method in `ToolFactory.kt` -2. Register in `registerAllTools()` +1. Add the tool to the appropriate registrar in `tools/` using the `ToolScope` DSL +2. The tool is automatically registered via `ToolFactory.registerAllTools()` ## Error Codes diff --git a/openspec/changes/refactor-toolfactory/tasks.md b/openspec/changes/refactor-toolfactory/tasks.md index 20aa084..46ea2bd 100644 --- a/openspec/changes/refactor-toolfactory/tasks.md +++ b/openspec/changes/refactor-toolfactory/tasks.md @@ -31,7 +31,7 @@ ## 7. Verify and Document -- [ ] 7.1 Run `./gradlew test` — all existing tests must pass with updated imports. -- [ ] 7.2 Run `./gradlew build` — full build must succeed with no warnings related to the refactor. -- [ ] 7.3 Update `CLAUDE.md` Architecture Overview section to reflect the new `tools/` and `discovery/` packages and file structure. -- [ ] 7.4 Update `CLAUDE.md` Unit Tests section to reflect the renamed/relocated test files. +- [x] 7.1 Run `./gradlew test` — all existing tests must pass with updated imports. +- [x] 7.2 Run `./gradlew build` — full build must succeed with no warnings related to the refactor. +- [x] 7.3 Update `CLAUDE.md` Architecture Overview section to reflect the new `tools/` and `discovery/` packages and file structure. +- [x] 7.4 Update `CLAUDE.md` Unit Tests section to reflect the renamed/relocated test files. From ce3d6fd03c4b2c5773a25954a4016ec1af6ccd56 Mon Sep 17 00:00:00 2001 From: docer1990 Date: Mon, 23 Mar 2026 13:16:01 +0100 Subject: [PATCH 08/10] feat: deduplicate boolean/direction validation into ToolDsl extension --- .../tools/AndroidAutomationToolRegistrar.kt | 20 +++---------------- .../tools/AndroidDeviceToolRegistrar.kt | 1 - .../tools/IOSAutomationToolRegistrar.kt | 14 ++----------- .../com/example/visiontest/tools/ToolDsl.kt | 19 ++++++++++++++++++ 4 files changed, 24 insertions(+), 30 deletions(-) diff --git a/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt index edfd0a6..9d49ebe 100644 --- a/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt +++ b/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt @@ -302,11 +302,7 @@ class AndroidAutomationToolRegistrar( return@tool "Automation server is not running. Use 'start_automation_server' first." } - val direction = request.requireString("direction") - val validDirections = listOf("up", "down", "left", "right") - if (direction.lowercase() !in validDirections) { - return@tool "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" - } + val direction = request.requireDirection() val distance = request.optionalString("distance") ?: "medium" val speed = request.optionalString("speed") ?: "normal" @@ -357,11 +353,7 @@ class AndroidAutomationToolRegistrar( return@tool "Automation server is not running. Use 'start_automation_server' first." } - val direction = request.requireString("direction") - val validDirections = listOf("up", "down", "left", "right") - if (direction.lowercase() !in validDirections) { - return@tool "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" - } + val direction = request.requireDirection() val text = request.optionalString("text") val textContains = request.optionalString("textContains") @@ -533,13 +525,7 @@ class AndroidAutomationToolRegistrar( return@tool "Automation server is not running. Use 'start_automation_server' first." } - val includeDisabledRaw = request.optionalString("includeDisabled") - val includeDisabled = when (includeDisabledRaw) { - null -> false - "true" -> true - "false" -> false - else -> return@tool "Invalid value for 'includeDisabled': '$includeDisabledRaw'. Must be true or false." - } + val includeDisabled = request.optionalBoolean("includeDisabled") ?: false automationClient.getInteractiveElements(includeDisabled) } diff --git a/app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt index 8436f77..a8596d6 100644 --- a/app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt +++ b/app/src/main/kotlin/com/example/visiontest/tools/AndroidDeviceToolRegistrar.kt @@ -1,7 +1,6 @@ package com.example.visiontest.tools import com.example.visiontest.common.DeviceConfig -import com.example.visiontest.utils.ErrorHandler.PACKAGE_NAME_REQUIRED import io.modelcontextprotocol.kotlin.sdk.Tool class AndroidDeviceToolRegistrar( diff --git a/app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt index 2887d54..55fb5e8 100644 --- a/app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt +++ b/app/src/main/kotlin/com/example/visiontest/tools/IOSAutomationToolRegistrar.kt @@ -266,13 +266,7 @@ class IOSAutomationToolRegistrar( return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." } - val includeDisabledRaw = request.optionalString("includeDisabled") - val includeDisabled = when (includeDisabledRaw) { - null -> false - "true" -> true - "false" -> false - else -> return@tool "Invalid value for 'includeDisabled': '$includeDisabledRaw'. Must be true or false." - } + val includeDisabled = request.optionalBoolean("includeDisabled") ?: false val bundleId = request.optionalString("bundleId") iosAutomationClient.getInteractiveElements(includeDisabled, bundleId) @@ -352,11 +346,7 @@ class IOSAutomationToolRegistrar( return@tool "iOS automation server is not running. Use 'ios_start_automation_server' first." } - val direction = request.requireString("direction") - val validDirections = listOf("up", "down", "left", "right") - if (direction.lowercase() !in validDirections) { - return@tool "Error: Invalid direction '$direction'. Must be one of: ${validDirections.joinToString()}" - } + val direction = request.requireDirection() val distance = request.optionalString("distance") ?: "medium" val speed = request.optionalString("speed") ?: "normal" diff --git a/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt index 897fece..5669085 100644 --- a/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt +++ b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt @@ -66,3 +66,22 @@ fun CallToolRequest.optionalInt(key: String): Int? { return raw.toIntOrNull() ?: throw IllegalArgumentException("Parameter '$key' must be an integer, got '$raw'") } + +fun CallToolRequest.optionalBoolean(key: String): Boolean? { + val raw = this.optionalString(key) ?: return null + return when (raw) { + "true" -> true + "false" -> false + else -> throw IllegalArgumentException("Parameter '$key' must be true or false, got '$raw'") + } +} + +private val VALID_DIRECTIONS = listOf("up", "down", "left", "right") + +fun CallToolRequest.requireDirection(key: String = "direction"): String { + val direction = this.requireString(key) + if (direction.lowercase() !in VALID_DIRECTIONS) { + throw IllegalArgumentException("Invalid direction '$direction'. Must be one of: ${VALID_DIRECTIONS.joinToString()}") + } + return direction +} From ff8cc2bc837b926a7f2f5ebc6a4909ef30cd980b Mon Sep 17 00:00:00 2001 From: docer1990 Date: Tue, 24 Mar 2026 12:26:31 +0100 Subject: [PATCH 09/10] fix: correct timeout error code mapping in ToolScope --- .../com/example/visiontest/tools/ToolDsl.kt | 7 + .../example/visiontest/tools/ToolDslTest.kt | 250 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt diff --git a/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt index 5669085..1255296 100644 --- a/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt +++ b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt @@ -6,8 +6,10 @@ import io.modelcontextprotocol.kotlin.sdk.CallToolResult import io.modelcontextprotocol.kotlin.sdk.TextContent import io.modelcontextprotocol.kotlin.sdk.Tool import io.modelcontextprotocol.kotlin.sdk.server.Server +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.jsonPrimitive +import java.util.concurrent.TimeoutException import org.slf4j.Logger /** @@ -37,6 +39,11 @@ class ToolScope( handler(request) } CallToolResult(content = listOf(TextContent(result))) + } catch (e: TimeoutCancellationException) { + ErrorHandler.handleToolError( + TimeoutException("Tool '$name' timed out after ${timeoutMs}ms"), + logger, name + ) } catch (e: Exception) { ErrorHandler.handleToolError(e, logger, name) } diff --git a/app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt b/app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt new file mode 100644 index 0000000..d116750 --- /dev/null +++ b/app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt @@ -0,0 +1,250 @@ +package com.example.visiontest.tools + +import com.example.visiontest.utils.ErrorHandler +import io.modelcontextprotocol.kotlin.sdk.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.Tool +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.slf4j.LoggerFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ToolDslTest { + + // ===== CallToolRequest extension helpers ===== + + private fun request(vararg pairs: Pair): CallToolRequest { + val args = JsonObject(pairs.associate { (k, v) -> k to JsonPrimitive(v) }) + return CallToolRequest(name = "test", arguments = args) + } + + // --- requireString --- + + @Test + fun `requireString returns value when present`() { + val req = request("key" to "hello") + assertEquals("hello", req.requireString("key")) + } + + @Test + fun `requireString throws when key missing`() { + val req = request() + val ex = assertFailsWith { req.requireString("key") } + assertTrue(ex.message!!.contains("Missing required parameter 'key'")) + } + + // --- requireInt --- + + @Test + fun `requireInt returns parsed int`() { + val req = request("x" to "42") + assertEquals(42, req.requireInt("x")) + } + + @Test + fun `requireInt throws on non-integer`() { + val req = request("x" to "abc") + val ex = assertFailsWith { req.requireInt("x") } + assertTrue(ex.message!!.contains("must be an integer")) + } + + @Test + fun `requireInt throws when key missing`() { + val req = request() + assertFailsWith { req.requireInt("x") } + } + + // --- optionalString --- + + @Test + fun `optionalString returns value when present`() { + val req = request("key" to "val") + assertEquals("val", req.optionalString("key")) + } + + @Test + fun `optionalString returns null when missing`() { + val req = request() + assertNull(req.optionalString("key")) + } + + // --- optionalInt --- + + @Test + fun `optionalInt returns parsed int when present`() { + val req = request("n" to "7") + assertEquals(7, req.optionalInt("n")) + } + + @Test + fun `optionalInt returns null when missing`() { + val req = request() + assertNull(req.optionalInt("n")) + } + + @Test + fun `optionalInt throws on non-integer value`() { + val req = request("n" to "abc") + val ex = assertFailsWith { req.optionalInt("n") } + assertTrue(ex.message!!.contains("must be an integer")) + } + + // --- optionalBoolean --- + + @Test + fun `optionalBoolean returns true`() { + val req = request("flag" to "true") + assertEquals(true, req.optionalBoolean("flag")) + } + + @Test + fun `optionalBoolean returns false`() { + val req = request("flag" to "false") + assertEquals(false, req.optionalBoolean("flag")) + } + + @Test + fun `optionalBoolean returns null when missing`() { + val req = request() + assertNull(req.optionalBoolean("flag")) + } + + @Test + fun `optionalBoolean throws on invalid value`() { + val req = request("flag" to "yes") + val ex = assertFailsWith { req.optionalBoolean("flag") } + assertTrue(ex.message!!.contains("must be true or false")) + } + + // --- requireDirection --- + + @Test + fun `requireDirection accepts valid directions`() { + for (dir in listOf("up", "down", "left", "right")) { + val req = request("direction" to dir) + assertEquals(dir, req.requireDirection()) + } + } + + @Test + fun `requireDirection is case-insensitive for validation`() { + val req = request("direction" to "UP") + assertEquals("UP", req.requireDirection()) + } + + @Test + fun `requireDirection throws on invalid direction`() { + val req = request("direction" to "diagonal") + val ex = assertFailsWith { req.requireDirection() } + assertTrue(ex.message!!.contains("Invalid direction")) + } + + @Test + fun `requireDirection uses custom key`() { + val req = request("swipeDir" to "left") + assertEquals("left", req.requireDirection("swipeDir")) + } + + // ===== ToolScope ===== + + private val logger = LoggerFactory.getLogger(ToolDslTest::class.java) + + /** + * Captures the handler registered via Server.addTool so we can invoke it directly, + * bypassing Server's private handleCallTool. + */ + private fun captureHandler( + defaultTimeoutMs: Long = 10_000L, + timeoutMs: Long? = null, + handler: suspend (CallToolRequest) -> String + ): suspend (CallToolRequest) -> CallToolResult { + val server = mockk(relaxed = true) + val handlerSlot = slot CallToolResult>() + + val scope = ToolScope(server, logger, defaultTimeoutMs) + scope.tool( + name = "test_tool", + description = "test", + timeoutMs = timeoutMs ?: defaultTimeoutMs, + handler = handler + ) + + verify { + server.addTool( + name = "test_tool", + description = "test", + inputSchema = any(), + handler = capture(handlerSlot) + ) + } + + return handlerSlot.captured + } + + @Test + fun `tool returns successful result`() = runBlocking { + val captured = captureHandler { "Hello!" } + + val result = captured(request()) + val text = (result.content.first() as TextContent).text + assertEquals("Hello!", text) + } + + @Test + fun `tool wraps handler exception via ErrorHandler`() = runBlocking { + val captured = captureHandler { + throw IllegalArgumentException("bad input") + } + + val result = captured(request()) + val text = (result.content.first() as TextContent).text!! + assertTrue(text.contains(ErrorHandler.ERROR_INVALID_ARG)) + } + + @Test + fun `tool maps timeout to ERR_TIMEOUT`() = runBlocking { + val captured = captureHandler(defaultTimeoutMs = 50L) { + delay(5_000L) + "never" + } + + val result = captured(request()) + val text = (result.content.first() as TextContent).text!! + assertTrue(text.contains(ErrorHandler.ERROR_TIMEOUT), "Expected ERR_TIMEOUT in: $text") + } + + @Test + fun `tool timeout message includes tool name`() = runBlocking { + val captured = captureHandler(defaultTimeoutMs = 50L) { + delay(5_000L) + "never" + } + + val result = captured(request()) + val text = (result.content.first() as TextContent).text!! + assertTrue(text.contains("test_tool"), "Expected tool name in: $text") + } + + @Test + fun `tool respects per-tool timeout override`() = runBlocking { + val captured = captureHandler(defaultTimeoutMs = 10_000L, timeoutMs = 50L) { + delay(5_000L) + "never" + } + + val result = captured(request()) + val text = (result.content.first() as TextContent).text!! + assertTrue(text.contains(ErrorHandler.ERROR_TIMEOUT), "Expected ERR_TIMEOUT in: $text") + } +} From d56e3297cd76af662ddb8315953ab2ed93a2d3ea Mon Sep 17 00:00:00 2001 From: docer1990 Date: Tue, 24 Mar 2026 12:54:51 +0100 Subject: [PATCH 10/10] fix: apply review suggestion --- .../visiontest/tools/AndroidAutomationToolRegistrar.kt | 1 + .../kotlin/com/example/visiontest/tools/ToolDsl.kt | 3 +++ .../kotlin/com/example/visiontest/tools/ToolDslTest.kt | 10 ++++++++++ 3 files changed, 14 insertions(+) diff --git a/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt b/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt index 9d49ebe..b562a33 100644 --- a/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt +++ b/app/src/main/kotlin/com/example/visiontest/tools/AndroidAutomationToolRegistrar.kt @@ -87,6 +87,7 @@ class AndroidAutomationToolRegistrar( ) ProcessBuilder(command) .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) .start() } diff --git a/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt index 1255296..5638773 100644 --- a/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt +++ b/app/src/main/kotlin/com/example/visiontest/tools/ToolDsl.kt @@ -6,6 +6,7 @@ import io.modelcontextprotocol.kotlin.sdk.CallToolResult import io.modelcontextprotocol.kotlin.sdk.TextContent import io.modelcontextprotocol.kotlin.sdk.Tool import io.modelcontextprotocol.kotlin.sdk.server.Server +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.jsonPrimitive @@ -44,6 +45,8 @@ class ToolScope( TimeoutException("Tool '$name' timed out after ${timeoutMs}ms"), logger, name ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { ErrorHandler.handleToolError(e, logger, name) } diff --git a/app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt b/app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt index d116750..256a5c8 100644 --- a/app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt +++ b/app/src/test/kotlin/com/example/visiontest/tools/ToolDslTest.kt @@ -9,6 +9,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.Server import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonObject @@ -236,6 +237,15 @@ class ToolDslTest { assertTrue(text.contains("test_tool"), "Expected tool name in: $text") } + @Test + fun `tool rethrows CancellationException for structured concurrency`() = runBlocking { + val captured = captureHandler { + throw CancellationException("job cancelled") + } + + assertFailsWith { captured(request()) } + } + @Test fun `tool respects per-tool timeout override`() = runBlocking { val captured = captureHandler(defaultTimeoutMs = 10_000L, timeoutMs = 50L) {