diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 56c9444..ef59922 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -98,9 +98,87 @@ jobs: -destination 'platform=iOS Simulator,name=iPhone 17' \ -only-testing:IOSAutomationServerTests + ios-release-build: + name: iOS Release Build + runs-on: macos-26 + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Select Xcode Version + run: sudo xcode-select -switch /Applications/Xcode.app + + - name: Show Xcode version + run: xcodebuild -version + + - name: Cache SPM dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ios-automation-server/IOSAutomationServer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm + ~/Library/Developer/Xcode/DerivedData/**/SourcePackages + build/SourcePackages + key: spm-${{ hashFiles('ios-automation-server/IOSAutomationServer.xcodeproj/project.pbxproj') }} + restore-keys: spm- + + - name: Build for testing + run: | + xcodebuild build-for-testing \ + -project ios-automation-server/IOSAutomationServer.xcodeproj \ + -scheme IOSAutomationServer \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -derivedDataPath build/ + + - name: Verify __TESTROOT__ placeholders in xctestrun + run: | + XCTESTRUN=$(find build/Build/Products -name '*.xctestrun' -print -quit) + if [ -z "$XCTESTRUN" ]; then + echo "Error: No .xctestrun file found" + exit 1 + fi + echo "Found xctestrun: $XCTESTRUN" + # Convert plist to XML first — Xcode may emit binary plists, making plain grep unreliable + XML_PLIST=$(plutil -convert xml1 -o - "$XCTESTRUN") || { + echo "Error: Failed to convert .xctestrun plist to XML" + exit 1 + } + if grep -q '__TESTROOT__' <<< "$XML_PLIST"; then + echo "xctestrun uses __TESTROOT__ placeholders (portable)" + else + echo "Error: xctestrun does not contain __TESTROOT__ placeholders — bundle will not be portable" + exit 1 + fi + # Reject CI-specific absolute paths that would break on user machines + if grep -qE '/Users/' <<< "$XML_PLIST"; then + echo "Error: xctestrun contains non-portable /Users/ paths" + grep -nE '/Users/' <<< "$XML_PLIST" || true + exit 1 + fi + + - name: Archive iOS test bundle + run: | + cd build/Build/Products + tar -czf "$GITHUB_WORKSPACE/ios-automation-server.tar.gz" \ + *.xctestrun \ + Debug-iphonesimulator/IOSAutomationServer.app \ + Debug-iphonesimulator/IOSAutomationServerUITests-Runner.app + + - name: Generate checksum + run: shasum -a 256 ios-automation-server.tar.gz > ios-automation-server.tar.gz.sha256 + + - name: Upload iOS bundle artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ios-automation-server + path: | + ios-automation-server.tar.gz + ios-automation-server.tar.gz.sha256 + release: name: Build & Release - needs: [mcp-server-tests, automation-server-tests, ios-automation-tests] + needs: [mcp-server-tests, automation-server-tests, ios-automation-tests, ios-release-build] runs-on: ubuntu-latest timeout-minutes: 15 @@ -133,6 +211,12 @@ jobs: - name: Verify JAR exists run: ls -lh app/build/libs/visiontest.jar + - name: Download iOS bundle artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ios-automation-server + path: release-staging/ + - name: Stage APKs and generate checksums run: | STAGING="release-staging" @@ -159,7 +243,8 @@ jobs: # Verify all files exist for f in visiontest.jar visiontest.jar.sha256 \ automation-server.apk automation-server.apk.sha256 \ - automation-server-test.apk automation-server-test.apk.sha256; do + automation-server-test.apk automation-server-test.apk.sha256 \ + ios-automation-server.tar.gz ios-automation-server.tar.gz.sha256; do if [ ! -f "$f" ]; then echo "Error: $f not created" exit 1 @@ -177,5 +262,7 @@ jobs: release-staging/automation-server.apk.sha256 release-staging/automation-server-test.apk release-staging/automation-server-test.apk.sha256 + release-staging/ios-automation-server.tar.gz + release-staging/ios-automation-server.tar.gz.sha256 install.sh run-visiontest.sh diff --git a/CLAUDE.md b/CLAUDE.md index 2852f7c..b30ddd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,15 +75,16 @@ Users install with `curl -fsSL https://github.com/docer1990/visiontest/releases/ 3. Fetches latest release tag from GitHub API, validates format (`v[0-9][0-9A-Za-z._-]*`) and rejects dangerous characters 4. Downloads `visiontest.jar` + SHA-256 checksum, verifies integrity 5. Downloads Android APKs (`automation-server.apk`, `automation-server-test.apk`) + checksums, verifies integrity -6. Installs JAR and APKs to `~/.local/share/visiontest/` (customizable via `VISIONTEST_DIR` env var, must be under `$HOME`) -7. Creates wrapper script at `~/.local/bin/visiontest`, ensures PATH -8. Does not modify Claude Desktop configuration; use `run-visiontest.sh` or manual setup for Claude integration. +6. On macOS arm64: downloads `ios-automation-server.tar.gz` + checksum, extracts pre-built iOS XCUITest bundle to `ios-automation-server/` subdirectory (skipped on Linux and macOS x86_64) +7. Installs JAR, APKs, and iOS bundle to `~/.local/share/visiontest/` (customizable via `VISIONTEST_DIR` env var, must be under `$HOME`) +8. Creates wrapper script at `~/.local/bin/visiontest`, ensures PATH +9. Does not modify Claude Desktop configuration; use `run-visiontest.sh` or manual setup for Claude integration. **Security hardening:** `umask 077`, explicit `chmod` on all files/dirs, tag validation, checksum verification, install path restricted to `$HOME`. ### Release Workflow (`.github/workflows/release.yaml`) -Triggered by git tags matching `v*`. The workflow runs the test suite, builds the fat JAR via `shadowJar` and Android APKs, generates SHA-256 checksums, and creates a GitHub Release with the following assets: `visiontest.jar`, `visiontest.jar.sha256`, `automation-server.apk`, `automation-server.apk.sha256`, `automation-server-test.apk`, `automation-server-test.apk.sha256`, `install.sh`, `run-visiontest.sh`. +Triggered by git tags matching `v*`. The workflow runs the test suite, builds the fat JAR via `shadowJar`, Android APKs, and the pre-built iOS XCUITest bundle (on a macOS runner), generates SHA-256 checksums, and creates a GitHub Release with the following assets: `visiontest.jar`, `visiontest.jar.sha256`, `automation-server.apk`, `automation-server.apk.sha256`, `automation-server-test.apk`, `automation-server-test.apk.sha256`, `ios-automation-server.tar.gz`, `ios-automation-server.tar.gz.sha256`, `install.sh`, `run-visiontest.sh`. All GitHub Actions in both workflows are pinned to commit SHAs for supply-chain security. When updating or adding actions, always use SHA-pinned references instead of floating version tags. @@ -236,7 +237,7 @@ Native iOS app providing XCUITest access via JSON-RPC. Uses **XCUITest framework | `ios_stop_automation_server` | Stop the running XCUITest server | **Typical iOS Automation Workflow:** -1. `ios_start_automation_server` - Build and start XCUITest server (handles build + install) +1. `ios_start_automation_server` - Start XCUITest server (uses pre-built bundle if available, otherwise builds from source) 2. `ios_get_interactive_elements` - Get filtered list of interactive elements (preferred) - OR `ios_get_ui_hierarchy` - Get full XML hierarchy (when you need all elements) 3. `ios_tap_by_coordinates` - Tap using centerX/centerY from interactive elements @@ -429,7 +430,7 @@ See `.claude/unit-testing-strategy.md` for the full testing roadmap (Plans 1-7). - JDK 17+ - macOS or Linux (arm64 or x86_64) - Android Platform Tools (ADB) in PATH — for Android automation -- Xcode Command Line Tools — for iOS simulator support (macOS only) +- Xcode Command Line Tools — for iOS simulator support (macOS only). Pre-built iOS bundle requires the same Xcode major version used in CI (see release notes). For source builds or Intel Macs, the full Xcode IDE is needed. - Android SDK — only needed for building the automation-server module from source > **Quick start:** Users who just need the MCP server can run `curl -fsSL https://github.com/docer1990/visiontest/releases/latest/download/install.sh | bash` — only Java 17+ is required. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9c4c8ad --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,323 @@ +# Contributing to VisionTest + +## Build from Source + +```bash +git clone https://github.com/docer1990/visiontest.git +cd visiontest + +# Build MCP Server JAR +./gradlew shadowJar +# Output: app/build/libs/visiontest.jar + +# Build Android Automation Server APKs +./gradlew :automation-server:assembleDebug :automation-server:assembleDebugAndroidTest + +# Install both APKs on a connected device (required for Android automation) +./gradlew :automation-server:installDebug :automation-server:installDebugAndroidTest + +# Build iOS test bundle (macOS only) +xcodebuild build-for-testing \ + -project ios-automation-server/IOSAutomationServer.xcodeproj \ + -scheme IOSAutomationServer \ + -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +### Configure Claude Desktop (from source) + +```json +{ + "mcpServers": { + "visiontest": { + "command": "/ABSOLUTE/PATH/TO/visiontest/run-visiontest.sh" + } + } +} +``` + +The `run-visiontest.sh` launcher handles `JAVA_HOME`, `ANDROID_HOME`, and APK path setup automatically. + +## Architecture + +VisionTest has three components: + +1. **MCP Server** (`app/`) — Kotlin/JVM server that exposes mobile automation tools via Model Context Protocol (stdio transport) +2. **Android Automation Server** (`automation-server/`) — Native Android app with UIAutomator API access via JSON-RPC, using the instrumentation pattern (like Maestro/Appium) +3. **iOS Automation Server** (`ios-automation-server/`) — Native iOS app with XCUITest access via JSON-RPC + +### Project Structure + +``` +visiontest/ +├── app/ # MCP Server (Kotlin/JVM) +│ └── src/main/kotlin/com/example/visiontest/ +│ ├── Main.kt # Entry point +│ ├── ToolFactory.kt # MCP tool registration +│ ├── android/ +│ │ ├── Android.kt # ADB communication (Adam library) +│ │ └── AutomationClient.kt # JSON-RPC client (Android) +│ ├── ios/ +│ │ ├── IOSManager.kt # iOS simulator operations +│ │ └── IOSAutomationClient.kt # JSON-RPC client (iOS) +│ └── config/ +│ ├── AppConfig.kt # MCP server config +│ ├── AutomationConfig.kt # Android automation constants +│ └── IOSAutomationConfig.kt # iOS automation constants +│ +├── automation-server/ # Android Automation Server +│ └── src/ +│ ├── main/ # Main app (config UI only) +│ │ ├── MainActivity.kt +│ │ ├── config/ServerConfig.kt +│ │ ├── jsonrpc/JsonRpcModels.kt +│ │ └── uiautomator/ +│ │ ├── BaseUiAutomatorBridge.kt +│ │ └── UiAutomatorModels.kt +│ └── androidTest/ # Instrumentation (actual server) +│ ├── AutomationServerTest.kt +│ ├── AutomationInstrumentationRunner.kt +│ ├── JsonRpcServerInstrumented.kt +│ └── UiAutomatorBridgeInstrumented.kt +│ +├── ios-automation-server/ # iOS Automation Server (Xcode) +│ ├── IOSAutomationServer.xcodeproj +│ ├── IOSAutomationServer/ # Minimal host app +│ │ └── AppDelegate.swift +│ └── IOSAutomationServerUITests/ # XCUITest server +│ ├── AutomationServerUITest.swift # Entry point +│ ├── Server/JsonRpcServer.swift # Swifter HTTP server +│ ├── Bridge/XCUITestBridge.swift # All XCUITest logic +│ └── Models/ +│ ├── JsonRpcModels.swift +│ └── AutomationModels.swift +│ +├── CLAUDE.md # AI assistant context +├── LEARNING.md # Architecture decisions & learnings +└── build.gradle.kts # Root build config +``` + +### Why Instrumentation? + +The Android automation server uses the instrumentation framework instead of a regular service: + +| Approach | UIAutomator Access | Security | +|----------|-------------------|----------| +| Exported Service | No | Risk | +| Regular Service | No | Safe | +| **Instrumentation** | **Yes** | **Safe** | + +UIAutomator requires a valid `Instrumentation` object to access `UiAutomation`. Only the test framework provides this — creating an empty `Instrumentation()` doesn't work. + +See [LEARNING.md](LEARNING.md) for deeper architecture decision records. + +### Flutter App Support + +Flutter apps expose text labels via `content-desc` (contentDescription) instead of `text`. When searching for elements: + +1. First try `find_element` with the `text` parameter +2. If not found, retry with `contentDescription` parameter + +The automation server uses reflection-based hierarchy dumping via `UiDevice.getWindowRoots()` (inspired by Maestro) to support Flutter and other cross-platform frameworks. + +## JSON-RPC API + +Both automation servers expose a JSON-RPC 2.0 API. Most users interact through the MCP tools, but the API is useful for debugging and direct integration. + +**Android**: `POST http://localhost:9008/jsonrpc` | Health: `GET http://localhost:9008/health` +**iOS**: `POST http://localhost:9009/jsonrpc` | Health: `GET http://localhost:9009/health` + +### Available Methods + +| Method | Parameters | Android | iOS | +|--------|------------|---------|-----| +| `ui.dumpHierarchy` | - | Yes | Yes | +| `ui.tapByCoordinates` | `x`, `y` | Yes | Yes | +| `ui.swipe` | `startX`, `startY`, `endX`, `endY`, `steps` | Yes | Yes | +| `ui.swipeByDirection` | `direction`, `distance`, `speed` | Yes | Yes | +| `ui.swipeOnElement` | `direction`, selector, `speed` | Yes | No | +| `ui.findElement` | `text`, `resourceId`, etc. | Yes | Yes | +| `ui.getInteractiveElements` | `includeDisabled` | Yes | Yes | +| `device.getInfo` | - | Yes | Yes | +| `ui.inputText` | `text` | Yes | Yes | +| `device.pressBack` | - | Yes | No | +| `device.pressHome` | - | Yes | Yes | + +### Example Requests + +```bash +# Android +curl -X POST http://localhost:9008/jsonrpc \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"ui.dumpHierarchy","id":1}' + +# iOS +curl -X POST http://localhost:9009/jsonrpc \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"device.getInfo","id":1}' +``` + +## MCP Tools Reference + +### Device Management + +| Tool | Platform | Description | +|------|----------|-------------| +| `available_device_android` | Android | Get first available device info | +| `list_apps_android` | Android | List installed apps | +| `info_app_android` | Android | Get app details (requires `packageName`) | +| `launch_app_android` | Android | Launch app (requires `packageName`) | +| `ios_available_device` | iOS | Get first available simulator info | +| `ios_list_apps` | iOS | List installed apps | +| `ios_info_app` | iOS | Get app details (requires `bundleId`) | +| `ios_launch_app` | iOS | Launch app (requires `bundleId`) | + +### UI Automation (Android) + +| Tool | Description | +|------|-------------| +| `install_automation_server` | Install both APKs on device | +| `start_automation_server` | Start JSON-RPC server via instrumentation | +| `automation_server_status` | Check if server is running | +| `get_ui_hierarchy` | Get XML of all visible UI elements | +| `get_interactive_elements` | Get filtered list of interactive elements | +| `find_element` | Find element by text, resourceId, className, etc. | +| `android_tap_by_coordinates` | Tap at screen coordinates | +| `android_swipe` | Swipe by coordinates | +| `android_swipe_direction` | Swipe by direction with distance and speed | +| `android_swipe_on_element` | Swipe on a specific element | +| `android_get_device_info` | Get display size, rotation, SDK version | +| `android_input_text` | Type text into the currently focused element | +| `android_press_back` | Press the back button | +| `android_press_home` | Press the home button | + +### UI Automation (iOS) + +| Tool | Description | +|------|-------------| +| `ios_start_automation_server` | Start XCUITest server (pre-built bundle or source build) | +| `ios_automation_server_status` | Check if server is running | +| `ios_get_ui_hierarchy` | Get XML of all visible UI elements | +| `ios_get_interactive_elements` | Get filtered list of interactive elements | +| `ios_find_element` | Find element by text, identifier, etc. | +| `ios_tap_by_coordinates` | Tap at screen coordinates | +| `ios_swipe` | Swipe by coordinates | +| `ios_swipe_direction` | Swipe by direction with distance and speed | +| `ios_get_device_info` | Get display size, rotation, iOS version | +| `ios_input_text` | Type text into the currently focused element | +| `ios_press_home` | Press home button | +| `ios_stop_automation_server` | Stop the running XCUITest server | + +## Testing + +### Running Tests + +```bash +# Run all Gradle unit tests (MCP server + Android automation server) +./gradlew test + +# Run only MCP server (app/) tests +./gradlew :app:test + +# Run only Android automation server tests +./gradlew :automation-server:test + +# Run a specific test class +./gradlew test --tests "ErrorHandlerTest" + +# Run iOS automation server unit tests +xcodebuild test \ + -project ios-automation-server/IOSAutomationServer.xcodeproj \ + -scheme IOSAutomationServer \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:IOSAutomationServerTests +``` + +All Gradle tests are pure JVM unit tests (no device or emulator required). iOS tests run on the simulator but don't need a running automation server. + +### Test Coverage + +| Module | Test File | Coverage Area | +|--------|-----------|---------------| +| `app/` | `ErrorHandlerTest.kt` | Exception-to-error-code mappings, retry with exponential backoff | +| `app/` | `ErrorHandlerCoroutineTest.kt` | Exponential backoff delays with `TestCoroutineScheduler` | +| `app/` | `IOSSimulatorParsingTest.kt` | Device list parsing, plist parsing, bundle ID & shell command validation | +| `app/` | `IOSSimulatorTest.kt` | Simulator operations with mocked ProcessExecutor | +| `app/` | `ProcessExecutorTest.kt` | Exit codes, stdout capture, timeout handling | +| `app/` | `IOSAutomationClientTest.kt` | JSON-RPC requests, `isServerRunning`, Gson serialization | +| `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` | +| `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 | +| `automation-server/` | `XmlUtilsTest.kt` | XML character stripping | +| `ios-automation-server/` | `JsonRpcModelsTests.swift` | JSON-RPC request parsing, error factory methods, error codes | +| `ios-automation-server/` | `AutomationModelsTests.swift` | Result model `toDictionary()` conversions, enum raw values | +| `ios-automation-server/` | `HelpersTests.swift` | `escapeXML`, `boundsString`, `intParam` type coercion | + +## Manual Testing + +### Android + +```bash +# Terminal 1: Start the server +adb shell am instrument -w -e port 9008 \ + -e class com.example.automationserver.AutomationServerTest#runAutomationServer \ + com.example.automationserver.test/com.example.automationserver.AutomationInstrumentationRunner + +# Terminal 2: Test the server +adb forward tcp:9008 tcp:9008 +curl http://localhost:9008/health + +# Stop the server +adb shell am force-stop com.example.automationserver +``` + +### iOS + +```bash +# Terminal 1: Start the server +xcodebuild test \ + -project ios-automation-server/IOSAutomationServer.xcodeproj \ + -scheme IOSAutomationServer \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:IOSAutomationServerUITests/AutomationServerUITest/testRunAutomationServer + +# Terminal 2: Test the server (no port forwarding needed) +curl http://localhost:9009/health +curl -X POST http://localhost:9009/jsonrpc -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"device.getInfo","id":1}' + +# Stop the server: kill the xcodebuild process (Ctrl+C in Terminal 1) +``` + +## Extending VisionTest + +### Adding New JSON-RPC Methods + +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` + +### Adding New MCP Tools + +1. Create method in `ToolFactory.kt` +2. Register in `registerAllTools()` + +## Error Codes + +| Code | Description | +|------|-------------| +| `ERR_NO_DEVICE` | No Android device available | +| `ERR_CMD_FAILED` | Command execution failed | +| `ERR_PKG_NOT_FOUND` | Package not found | +| `ERR_TIMEOUT` | Operation timed out | +| `ERR_NO_SIMULATOR` | No iOS simulator available | + +## Additional Resources + +- [CLAUDE.md](CLAUDE.md) — AI assistant context with full build commands and patterns +- [LEARNING.md](LEARNING.md) — Architecture decision records and design rationale diff --git a/README.md b/README.md index aa979b2..e751fe8 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,14 @@ # VisionTest - MCP Server for Mobile Automation -A platform-agnostic interface for mobile automation that enables LLMs and agents to interact with native mobile applications and devices. Supports Android devices (emulators and physical) and iOS simulators. - -## Overview - -VisionTest is an MCP (Model Context Protocol) server that provides a standardized way for AI agents and Large Language Models to interact with mobile devices. The project consists of three main components: - -1. **MCP Server** (`app/`) - Kotlin/JVM server that exposes mobile automation tools via Model Context Protocol -2. **Android Automation Server** (`automation-server/`) - Native Android app with UIAutomator API access via JSON-RPC, using the **instrumentation pattern** (like Maestro/Appium) -3. **iOS Automation Server** (`ios-automation-server/`) - Native iOS app with XCUITest access via JSON-RPC, using the same instrumentation pattern adapted for iOS - -This architecture allows for: - -- Device detection and information retrieval -- Application management (listing, info retrieval, launching) -- Direct UIAutomator access for advanced Android UI automation -- Direct XCUITest access for iOS simulator UI automation -- Secure automation via instrumentation frameworks on both platforms -- Scalable automation across multiple device types - -## Features - -### MCP Server -- **Device Management**: Detect and interact with connected Android devices and iOS simulators -- **App Management**: List installed apps, get detailed app information, and launch apps -- **UI Automation**: Get UI hierarchy, find elements, interact with UI via UIAutomator -- **Robust Error Handling**: Comprehensive exception framework with descriptive error messages -- **Performance Optimizations**: Device list caching to reduce ADB command overhead -- **Retry Logic**: Automatic retries with exponential backoff for flaky device operations - -### Android Automation Server -- **Instrumentation Pattern**: Uses Android's instrumentation framework for secure UIAutomator access -- **UIAutomator Integration**: Direct access to Android UIAutomator API -- **Flutter App Support**: Reflection-based hierarchy dumping via `getWindowRoots()` for Flutter and other frameworks -- **JSON-RPC Server**: HTTP-based JSON-RPC 2.0 interface for automation commands -- **Configuration UI**: Simple interface showing setup instructions and port configuration -- **No Exported Services**: Only accessible via ADB instrumentation for security - -### iOS Automation Server -- **XCUITest Framework**: Uses Apple's XCUITest for iOS simulator UI automation -- **JSON-RPC Server**: HTTP-based JSON-RPC 2.0 interface (via Swifter library) -- **No Port Forwarding**: iOS simulators share the Mac's network stack -- **Full UI Automation**: Tap, swipe, find elements, dump hierarchy, get interactive elements -- **Lightweight**: Swifter HTTP server in a UI test bundle +An MCP server that lets AI agents interact with Android devices and iOS simulators — tap, swipe, type, read UI elements, and launch apps. + +## What It Does + +- **Android + iOS** automation through a single MCP server +- **UI interaction**: tap, swipe, type text, find elements, read screen hierarchy +- **App management**: list, inspect, and launch apps +- **Device detection**: automatically finds connected Android devices and booted iOS simulators +- **Zero-config iOS**: uses pre-built test bundle when installed, falls back to source build if needed ## Prerequisites @@ -55,20 +21,17 @@ This architecture allows for: ### Quick Install (Recommended) -Install the MCP server with a single command: - ```bash curl -fsSL https://github.com/docer1990/visiontest/releases/latest/download/install.sh | bash ``` This will: - Check that Java 17+ is installed -- Download the latest release JAR to `~/.local/share/visiontest/` +- Download the latest release JAR, Android APKs, and iOS test bundle - Create a `visiontest` command in `~/.local/bin/` -- Print post-install instructions, including how to integrate with Claude Desktop -- Verify the download via SHA-256 checksum +- Verify all downloads via SHA-256 checksums -You can customize the install directory with the `VISIONTEST_DIR` environment variable: +You can customize the install directory: ```bash VISIONTEST_DIR="$HOME/my-tools/visiontest" curl -fsSL https://github.com/docer1990/visiontest/releases/latest/download/install.sh | bash @@ -76,7 +39,7 @@ VISIONTEST_DIR="$HOME/my-tools/visiontest" curl -fsSL https://github.com/docer19 To update, re-run the same command. -#### Configure Your AI Coding Tool +### Configure Your AI Coding Tool
Claude Code @@ -89,7 +52,7 @@ claude mcp add visiontest java -- -jar ~/.local/share/visiontest/visiontest.jar
Claude Desktop -To configure manually, edit the config file: +Edit the config file: - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - **Linux**: `~/.config/Claude/claude_desktop_config.json` @@ -160,91 +123,13 @@ Add to `opencode.json` (project root or `~/.config/opencode/opencode.json`): ### Build from Source -For development or if you need the Android Automation Server APKs: - -```bash -git clone https://github.com/docer1990/visiontest.git -cd visiontest - -# Build MCP Server JAR -./gradlew shadowJar -# Output: app/build/libs/visiontest.jar - -# Build Android Automation Server APKs -./gradlew :automation-server:assembleDebug :automation-server:assembleDebugAndroidTest - -# Install both APKs on a connected device (required for Android automation) -./gradlew :automation-server:installDebug :automation-server:installDebugAndroidTest -``` - -#### Configure Claude Desktop (from source) - -```json -{ - "mcpServers": { - "visiontest": { - "command": "/ABSOLUTE/PATH/TO/visiontest/run-visiontest.sh" - } - } -} -``` - -The `run-visiontest.sh` launcher handles `JAVA_HOME`, `ANDROID_HOME`, and APK path setup automatically. +For development or contributing, see [CONTRIBUTING.md](CONTRIBUTING.md). ## Usage -### Available MCP Tools - -#### Device Management - -| Tool | Platform | Description | -|------|----------|-------------| -| `available_device_android` | Android | Get first available device info | -| `list_apps_android` | Android | List installed apps | -| `info_app_android` | Android | Get app details (requires `packageName`) | -| `launch_app_android` | Android | Launch app (requires `packageName`) | -| `ios_available_device` | iOS | Get first available simulator info | -| `ios_list_apps` | iOS | List installed apps | -| `ios_info_app` | iOS | Get app details (requires `bundleId`) | -| `ios_launch_app` | iOS | Launch app (requires `bundleId`) | - -#### UI Automation (Android) - -| Tool | Description | -|------|-------------| -| `install_automation_server` | Install both APKs on device | -| `start_automation_server` | Start JSON-RPC server via instrumentation | -| `automation_server_status` | Check if server is running | -| `get_ui_hierarchy` | Get XML of all visible UI elements | -| `get_interactive_elements` | Get filtered list of interactive elements | -| `find_element` | Find element by text, resourceId, className, etc. | -| `android_tap_by_coordinates` | Tap at screen coordinates | -| `android_swipe` | Swipe by coordinates | -| `android_swipe_direction` | Swipe by direction with distance and speed | -| `android_swipe_on_element` | Swipe on a specific element | -| `android_get_device_info` | Get display size, rotation, SDK version | -| `android_input_text` | Type text into the currently focused element | -| `android_press_back` | Press the back button | -| `android_press_home` | Press the home button | - -#### UI Automation (iOS) - -| Tool | Description | -|------|-------------| -| `ios_start_automation_server` | Build + start XCUITest server on simulator | -| `ios_automation_server_status` | Check if server is running | -| `ios_get_ui_hierarchy` | Get XML of all visible UI elements | -| `ios_get_interactive_elements` | Get filtered list of interactive elements | -| `ios_find_element` | Find element by text, identifier, etc. | -| `ios_tap_by_coordinates` | Tap at screen coordinates | -| `ios_swipe` | Swipe by coordinates | -| `ios_swipe_direction` | Swipe by direction with distance and speed | -| `ios_get_device_info` | Get display size, rotation, iOS version | -| `ios_input_text` | Type text into the currently focused element | -| `ios_press_home` | Press home button | -| `ios_stop_automation_server` | Stop the running XCUITest server | - -#### Typical Android Workflow +Your AI coding tool discovers all available tools automatically via MCP. Just ask it to interact with a device and it will use the right tools. + +### Android Workflow ``` 1. install_automation_server → Install APKs (one-time setup) @@ -254,234 +139,22 @@ The `run-visiontest.sh` launcher handles `JAVA_HOME`, `ANDROID_HOME`, and APK pa 5. android_input_text → Type text into focused field ``` -#### Typical iOS Workflow +### iOS Workflow ``` -1. ios_start_automation_server → Build + start XCUITest server +1. ios_start_automation_server → Start XCUITest server (pre-built or source build) 2. ios_get_interactive_elements → Get interactive elements with tap coordinates 3. ios_tap_by_coordinates → Tap using centerX/centerY 4. ios_input_text → Type text into focused field ``` -### JSON-RPC API - -Both automation servers expose a JSON-RPC 2.0 API with compatible method names: - -**Android**: `POST http://localhost:9008/jsonrpc` | Health: `GET http://localhost:9008/health` -**iOS**: `POST http://localhost:9009/jsonrpc` | Health: `GET http://localhost:9009/health` - -#### Available Methods - -| Method | Parameters | Android | iOS | -|--------|------------|---------|-----| -| `ui.dumpHierarchy` | - | Yes | Yes | -| `ui.tapByCoordinates` | `x`, `y` | Yes | Yes | -| `ui.swipe` | `startX`, `startY`, `endX`, `endY`, `steps` | Yes | Yes | -| `ui.swipeByDirection` | `direction`, `distance`, `speed` | Yes | Yes | -| `ui.swipeOnElement` | `direction`, selector, `speed` | Yes | No | -| `ui.findElement` | `text`, `resourceId`, etc. | Yes | Yes | -| `ui.getInteractiveElements` | `includeDisabled` | Yes | Yes | -| `device.getInfo` | - | Yes | Yes | -| `ui.inputText` | `text` | Yes | Yes | -| `device.pressBack` | - | Yes | No | -| `device.pressHome` | - | Yes | Yes | - -#### Example Requests - -```bash -# Android -curl -X POST http://localhost:9008/jsonrpc \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"ui.dumpHierarchy","id":1}' - -# iOS -curl -X POST http://localhost:9009/jsonrpc \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"device.getInfo","id":1}' -``` - -## Architecture - -### Project Structure - -``` -visiontest/ -├── app/ # MCP Server (Kotlin/JVM) -│ └── src/main/kotlin/com/example/visiontest/ -│ ├── Main.kt # Entry point -│ ├── ToolFactory.kt # MCP tool registration -│ ├── android/ -│ │ ├── Android.kt # ADB communication (Adam library) -│ │ └── AutomationClient.kt # JSON-RPC client (Android) -│ ├── ios/ -│ │ ├── IOSManager.kt # iOS simulator operations -│ │ └── IOSAutomationClient.kt # JSON-RPC client (iOS) -│ └── config/ -│ ├── AppConfig.kt # MCP server config -│ ├── AutomationConfig.kt # Android automation constants -│ └── IOSAutomationConfig.kt # iOS automation constants -│ -├── automation-server/ # Android Automation Server -│ └── src/ -│ ├── main/ # Main app (config UI only) -│ │ ├── MainActivity.kt -│ │ ├── config/ServerConfig.kt -│ │ ├── jsonrpc/JsonRpcModels.kt -│ │ └── uiautomator/ -│ │ ├── BaseUiAutomatorBridge.kt -│ │ └── UiAutomatorModels.kt -│ └── androidTest/ # Instrumentation (actual server) -│ ├── AutomationServerTest.kt -│ ├── AutomationInstrumentationRunner.kt -│ ├── JsonRpcServerInstrumented.kt -│ └── UiAutomatorBridgeInstrumented.kt -│ -├── ios-automation-server/ # iOS Automation Server (Xcode) -│ ├── IOSAutomationServer.xcodeproj -│ ├── IOSAutomationServer/ # Minimal host app -│ │ └── AppDelegate.swift -│ └── IOSAutomationServerUITests/ # XCUITest server -│ ├── AutomationServerUITest.swift # Entry point -│ ├── Server/JsonRpcServer.swift # Swifter HTTP server -│ ├── Bridge/XCUITestBridge.swift # All XCUITest logic -│ └── Models/ -│ ├── JsonRpcModels.swift -│ └── AutomationModels.swift -│ -├── CLAUDE.md # AI assistant context -├── LEARNING.md # Architecture decisions & learnings -└── build.gradle.kts # Root build config -``` - -### Why Instrumentation? - -The automation server uses Android's instrumentation framework instead of a regular service: +### Available Tools -| Approach | UIAutomator Access | Security | -|----------|-------------------|----------| -| Exported Service | No | Risk | -| Regular Service | No | Safe | -| **Instrumentation** | **Yes** | **Safe** | +**Device Management:** `available_device_android`, `list_apps_android`, `info_app_android`, `launch_app_android`, `ios_available_device`, `ios_list_apps`, `ios_info_app`, `ios_launch_app` -UIAutomator requires a valid `Instrumentation` object to access `UiAutomation`. Only the test framework provides this - creating an empty `Instrumentation()` doesn't work. +**Android Automation:** `install_automation_server`, `start_automation_server`, `automation_server_status`, `get_ui_hierarchy`, `get_interactive_elements`, `find_element`, `android_tap_by_coordinates`, `android_swipe`, `android_swipe_direction`, `android_swipe_on_element`, `android_get_device_info`, `android_input_text`, `android_press_back`, `android_press_home` -### Flutter App Support - -The automation server uses a Maestro-inspired approach for Flutter app support: - -- **Reflection-based hierarchy**: Uses `UiDevice.getWindowRoots()` via reflection to access all accessibility window roots -- **Cross-app windows**: Enables `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` (API 24+) for accessing windows from other apps -- **Uncompressed hierarchy**: Sets `compressedLayoutHierarchy` to false to expose all accessibility nodes -- **WebView handling**: Includes WebView contents that may report as invisible - -**Finding Elements in Flutter Apps:** - -Flutter apps expose text labels via `content-desc` (contentDescription) instead of `text`. When searching for elements: - -1. First try `find_element` with the `text` parameter -2. If not found, retry with `contentDescription` parameter - -```bash -# Native Android app - use text -curl -X POST http://localhost:9008/jsonrpc \ - -d '{"jsonrpc":"2.0","method":"ui.findElement","params":{"text":"Log In"},"id":1}' - -# Flutter app - use contentDescription -curl -X POST http://localhost:9008/jsonrpc \ - -d '{"jsonrpc":"2.0","method":"ui.findElement","params":{"contentDescription":"Log In"},"id":1}' -``` - -### Manual Testing - -#### Android - -```bash -# Terminal 1: Start the server -adb shell am instrument -w -e port 9008 \ - -e class com.example.automationserver.AutomationServerTest#runAutomationServer \ - com.example.automationserver.test/com.example.automationserver.AutomationInstrumentationRunner - -# Terminal 2: Test the server -adb forward tcp:9008 tcp:9008 -curl http://localhost:9008/health - -# Stop the server -adb shell am force-stop com.example.automationserver -``` - -#### iOS - -```bash -# Terminal 1: Start the server -xcodebuild test \ - -project ios-automation-server/IOSAutomationServer.xcodeproj \ - -scheme IOSAutomationServer \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ - -only-testing:IOSAutomationServerUITests/AutomationServerUITest/testRunAutomationServer - -# Terminal 2: Test the server (no port forwarding needed) -curl http://localhost:9009/health -curl -X POST http://localhost:9009/jsonrpc -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"device.getInfo","id":1}' - -# Stop the server: kill the xcodebuild process (Ctrl+C in Terminal 1) -``` - -## Testing - -### Running Tests - -```bash -# Run all Gradle unit tests (MCP server + Android automation server) -./gradlew test - -# Run only MCP server (app/) tests -./gradlew :app:test - -# Run only Android automation server tests -./gradlew :automation-server:test - -# Run a specific test class -./gradlew test --tests "ErrorHandlerTest" - -# Run iOS automation server unit tests -xcodebuild test \ - -project ios-automation-server/IOSAutomationServer.xcodeproj \ - -scheme IOSAutomationServer \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ - -only-testing:IOSAutomationServerTests - -# Build iOS tests without running (resolves SPM dependencies + compiles) -xcodebuild build-for-testing \ - -project ios-automation-server/IOSAutomationServer.xcodeproj \ - -scheme IOSAutomationServer \ - -destination 'platform=iOS Simulator,name=iPhone 16' -``` - -All Gradle tests are pure JVM unit tests (no device or emulator required). iOS tests are pure unit tests that run on the simulator but don't need a running automation server. - -### Test Coverage - -| Module | Test File | Coverage Area | -|--------|-----------|---------------| -| `app/` | `ErrorHandlerTest.kt` | Exception-to-error-code mappings, retry with exponential backoff | -| `app/` | `ErrorHandlerCoroutineTest.kt` | Exponential backoff delays with `TestCoroutineScheduler` | -| `app/` | `IOSSimulatorParsingTest.kt` | Device list parsing, plist parsing, bundle ID & shell command validation | -| `app/` | `IOSSimulatorTest.kt` | Simulator operations with mocked ProcessExecutor (listDevices, getFirstAvailableDevice, launchApp, etc.) | -| `app/` | `ProcessExecutorTest.kt` | Exit codes, stdout capture, timeout handling, non-existent commands | -| `app/` | `IOSAutomationClientTest.kt` | JSON-RPC requests, `isServerRunning`, Gson serialization (MockWebServer) | -| `app/` | `AndroidValidationTest.kt` | Package name validation, ADB argument validation (forward, shell, install) | -| `app/` | `AutomationClientTest.kt` | `sendRequest` POST/params/errors, `isServerRunning` health check (MockWebServer) | -| `app/` | `AppConfigTest.kt` | Default configuration values | -| `app/` | `ToolFactoryHelpersTest.kt` | Property extraction, pattern matching, app info formatting | -| `app/` | `ToolFactoryPathTest.kt` | `findProjectRoot` (settings.gradle discovery, depth limit), `findAutomationServerApk` (env var, search roots, ordering) | -| `automation-server/` | `JsonRpcModelsTest.kt` | JSON-RPC error factory methods, request/response defaults and field handling | -| `automation-server/` | `UiAutomatorModelsTest.kt` | All data classes, default values, enum entries (SwipeSpeed, SwipeDirection, SwipeDistance) | -| `automation-server/` | `ServerConfigPortTest.kt` | Port validation boundaries, constants | -| `automation-server/` | `XmlUtilsTest.kt` | XML character stripping (invalid ranges, preserved chars, mixed input) | -| `ios-automation-server/` | `JsonRpcModelsTests.swift` | JSON-RPC request parsing, error factory methods, error codes, success/error responses | -| `ios-automation-server/` | `AutomationModelsTests.swift` | All result model `toDictionary()` conversions, enum raw values (SwipeDirection, SwipeDistance, SwipeSpeed) | -| `ios-automation-server/` | `HelpersTests.swift` | `escapeXML` character escaping, `boundsString` from CGRect, `intParam` type coercion | +**iOS Automation:** `ios_start_automation_server`, `ios_automation_server_status`, `ios_get_ui_hierarchy`, `ios_get_interactive_elements`, `ios_find_element`, `ios_tap_by_coordinates`, `ios_swipe`, `ios_swipe_direction`, `ios_get_device_info`, `ios_input_text`, `ios_press_home`, `ios_stop_automation_server` ## Configuration @@ -490,44 +163,14 @@ All Gradle tests are pure JVM unit tests (no device or emulator required). iOS t | Variable | Default | Description | |----------|---------|-------------| | `VISION_TEST_LOG_LEVEL` | `PRODUCTION` | `PRODUCTION`, `DEVELOPMENT`, `DEBUG` | -| `VISION_TEST_APK_PATH` | (auto-detected) | Explicit path to test APK | +| `VISION_TEST_APK_PATH` | (auto-detected) | Explicit path to Android test APK | +| `VISION_TEST_IOS_PROJECT_PATH` | (auto-detected) | Explicit path to iOS `.xcodeproj` | | `VISIONTEST_DIR` | `~/.local/share/visiontest` | Override install directory (must be under `$HOME`) | -### Default Timeouts (hardcoded in `AppConfig.kt`) - -- ADB timeout: 5000ms -- Device cache validity: 1000ms -- Tool execution timeout: 10000ms - -### Automation Server - -- **Android Default Port**: 9008 (configurable via app UI, range: 1024-65535) -- **iOS Default Port**: 9009 (no port forwarding needed) -- **Port Forwarding**: Automatically set up by MCP tools (Android only) - -## Error Codes - -| Code | Description | -|------|-------------| -| `ERR_NO_DEVICE` | No Android device available | -| `ERR_CMD_FAILED` | Command execution failed | -| `ERR_PKG_NOT_FOUND` | Package not found | -| `ERR_TIMEOUT` | Operation timed out | -| `ERR_NO_SIMULATOR` | No iOS simulator available | - -## Extending - -### Adding New JSON-RPC Methods - -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` - -### Adding New MCP Tools +### Ports -1. Create method in `ToolFactory.kt` -2. Register in `registerAllTools()` +- **Android**: 9008 (requires ADB port forwarding, set up automatically) +- **iOS**: 9009 (no port forwarding needed — simulators share the Mac's network) ## Future Plans @@ -546,7 +189,7 @@ All Gradle tests are pure JVM unit tests (no device or emulator required). iOS t ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +See [CONTRIBUTING.md](CONTRIBUTING.md) for build-from-source instructions, architecture details, JSON-RPC API reference, testing guide, and how to extend VisionTest. ## License diff --git a/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt b/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt index 600f696..30c2e61 100644 --- a/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt +++ b/app/src/main/kotlin/com/example/visiontest/ToolFactory.kt @@ -1109,13 +1109,110 @@ class ToolFactory( @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 + ) + + /** 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. - Builds and runs the XCUITest automation server via xcodebuild. - No installation step needed — xcodebuild handles build + install. + 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). @@ -1123,7 +1220,8 @@ class ToolFactory( inputSchema = Tool.Input() ) { try { - val result = runWithTimeout(120000) { + // 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 @@ -1140,59 +1238,48 @@ class ToolFactory( iosXcodebuildProcess = null } - // Find the Xcode project + // Discover launch path: pre-built bundle preferred, source build as fallback + val xctestrunPath = findXctestrun() val projectPath = findXcodeProject() - ?: return@runWithTimeout "Xcode project not found at ${IOSAutomationConfig.XCODE_PROJECT_PATH}. " + - "To fix: ensure you have a local checkout of the VisionTest repository that contains the iOS Xcode project, then " + - "set the ${IOSAutomationConfig.XCODE_PROJECT_PATH_ENV} environment variable to the absolute path of ${IOSAutomationConfig.XCODE_PROJECT_PATH} within that checkout." + + 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 - // Start xcodebuild test in background - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - val testId = "${IOSAutomationConfig.UI_TEST_TARGET}/${IOSAutomationConfig.TEST_CLASS}/${IOSAutomationConfig.TEST_METHOD}" - val command = listOf( - "xcodebuild", "test", - "-project", projectPath, - "-scheme", IOSAutomationConfig.XCODE_SCHEME, - "-destination", "platform=iOS Simulator,name=$simulatorName", - "-only-testing:$testId" - ) - logger.info("Starting iOS automation server: ${command.joinToString(" ")}") + // Pre-built path starts faster (no compilation), use shorter timeout + val command = buildXcodebuildCommand(xctestrunPath, projectPath, simulatorName) + val maxAttempts = if (usingPrebuilt) 30 else 60 - val process = ProcessBuilder(command) - .redirectErrorStream(true) - .redirectOutput(ProcessBuilder.Redirect.DISCARD) - .start() - iosXcodebuildProcess = process - } + val label = if (usingPrebuilt) "pre-built bundle" else "source build" + val primaryResult = startAndPollServer(command, maxAttempts, port, label) - // Wait for server to start and verify via health check - var attempts = 0 - val maxAttempts = 60 // xcodebuild needs time to build - while (attempts < maxAttempts) { - kotlinx.coroutines.delay(2000) - - // Check if xcodebuild exited early (build failure, signing error, etc.) - iosXcodebuildProcess?.let { process -> - if (!process.isAlive) { - val exitCode = process.exitValue() - iosXcodebuildProcess = null - return@runWithTimeout "xcodebuild exited with code $exitCode before the server started. Run the xcodebuild command manually to see build errors." - } - } + if (primaryResult.earlyExitCode == null) { + return@runWithTimeout primaryResult.message + } - if (iosAutomationClient.isServerRunning()) { - logger.info("iOS automation server started successfully") - return@runWithTimeout "iOS automation server started successfully. Server is listening on localhost:$port" - } - attempts++ - logger.debug("Waiting for iOS server to start... attempt $attempts/$maxAttempts") + // 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 } - "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." + primaryResult.message } CallToolResult( @@ -1684,21 +1771,30 @@ class ToolFactory( return null } - // ==================== Android APK Discovery ==================== + // ==================== Install Directory Resolution ==================== - internal fun findAutomationServerApk(): String? { - val cwd = File(".").absoluteFile - val codeSourceRoot = findCodeSourceRoot() - val jarDir = findJarDirectory() + /** + * 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() } - ?: jarDir?.absolutePath + ?: 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 = File(installDirPath) + installDir = resolveInstallDir() ) } @@ -1787,6 +1883,46 @@ class ToolFactory( 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 diff --git a/app/src/main/kotlin/com/example/visiontest/config/IOSAutomationConfig.kt b/app/src/main/kotlin/com/example/visiontest/config/IOSAutomationConfig.kt index 22d98a0..877c914 100644 --- a/app/src/main/kotlin/com/example/visiontest/config/IOSAutomationConfig.kt +++ b/app/src/main/kotlin/com/example/visiontest/config/IOSAutomationConfig.kt @@ -61,4 +61,9 @@ object IOSAutomationConfig { * Scheme name in the Xcode project. */ const val XCODE_SCHEME = "IOSAutomationServer" + + /** + * Subdirectory name for the pre-built iOS test bundle within the install directory. + */ + const val XCTESTRUN_BUNDLE_DIR = "ios-automation-server" } diff --git a/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt b/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt index 92a54b5..038e9fb 100644 --- a/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt +++ b/app/src/test/kotlin/com/example/visiontest/ToolFactoryPathTest.kt @@ -376,4 +376,102 @@ class ToolFactoryPathTest { assertNull(result) } + + // ==================== findXctestrun ==================== + + @Test + fun `findXctestrun finds xctestrun file in install directory`(@TempDir tempDir: File) { + val bundleDir = File(tempDir, "ios-automation-server").apply { mkdirs() } + File(bundleDir, "IOSAutomationServer_iphonesimulator18.0-arm64.xctestrun").createNewFile() + + val result = factory.findXctestrun(tempDir) + + assertNotNull(result) + assertTrue(result.endsWith(".xctestrun")) + } + + @Test + fun `findXctestrun returns null when install directory has no xctestrun`(@TempDir tempDir: File) { + File(tempDir, "ios-automation-server").mkdirs() + + val result = factory.findXctestrun(tempDir) + + assertNull(result) + } + + @Test + fun `findXctestrun returns null when install directory does not exist`(@TempDir tempDir: File) { + val nonExistent = File(tempDir, "nonexistent") + + val result = factory.findXctestrun(nonExistent) + + assertNull(result) + } + + @Test + fun `findXctestrun selects first file alphabetically when multiple xctestrun files exist`(@TempDir tempDir: File) { + val bundleDir = File(tempDir, "ios-automation-server").apply { mkdirs() } + File(bundleDir, "B_iphonesimulator18.0.xctestrun").createNewFile() + File(bundleDir, "A_iphonesimulator17.0.xctestrun").createNewFile() + + val result = factory.findXctestrun(tempDir) + + assertNotNull(result) + assertTrue(result.contains("A_iphonesimulator17.0.xctestrun")) + } + + @Test + fun `findXctestrun returns absolute path`(@TempDir tempDir: File) { + val bundleDir = File(tempDir, "ios-automation-server").apply { mkdirs() } + File(bundleDir, "Test.xctestrun").createNewFile() + + val result = factory.findXctestrun(tempDir) + + assertNotNull(result) + assertTrue(File(result).isAbsolute) + } + + @Test + fun `findXctestrun ignores non-xctestrun files`(@TempDir tempDir: File) { + val bundleDir = File(tempDir, "ios-automation-server").apply { mkdirs() } + File(bundleDir, "IOSAutomationServer.app").mkdirs() + File(bundleDir, "readme.txt").createNewFile() + + val result = factory.findXctestrun(tempDir) + + assertNull(result) + } + + // ==================== buildXcodebuildCommand ==================== + + @Test + fun `buildXcodebuildCommand produces test-without-building for pre-built path`() { + val command = factory.buildXcodebuildCommand( + xctestrunPath = "/path/to/Test.xctestrun", + projectPath = null, + simulatorName = "iPhone 16" + ) + + assertEquals("xcodebuild", command[0]) + assertEquals("test-without-building", command[1]) + assertTrue(command.contains("-xctestrun")) + assertTrue(command.contains("/path/to/Test.xctestrun")) + assertTrue(command.contains("platform=iOS Simulator,name=iPhone 16")) + } + + @Test + fun `buildXcodebuildCommand produces test for source path`() { + val command = factory.buildXcodebuildCommand( + xctestrunPath = null, + projectPath = "/path/to/Project.xcodeproj", + simulatorName = "iPhone 16" + ) + + assertEquals("xcodebuild", command[0]) + assertEquals("test", command[1]) + assertTrue(command.contains("-project")) + assertTrue(command.contains("/path/to/Project.xcodeproj")) + assertTrue(command.contains("-scheme")) + assertTrue(command.contains("platform=iOS Simulator,name=iPhone 16")) + } } diff --git a/install.sh b/install.sh index c032180..10a73df 100755 --- a/install.sh +++ b/install.sh @@ -256,6 +256,88 @@ download_apks() { ok "APKs installed to $RESOLVED_VISIONTEST_HOME/" } +# ---------- download iOS bundle (macOS arm64 only) ---------- + +download_ios_bundle() { + if [ "$PLATFORM" != "macOS" ]; then + info "Skipping iOS automation bundle (macOS only)" + return + fi + + if [ "$ARCH" != "arm64" ]; then + info "Skipping iOS automation bundle (pre-built bundle is arm64 only)" + info "For iOS automation on Intel Mac, build from source: clone the repo and use Xcode" + return + fi + + info "Downloading iOS automation bundle..." + + download_and_verify \ + "https://github.com/$REPO/releases/download/$LATEST_TAG/ios-automation-server.tar.gz" \ + "https://github.com/$REPO/releases/download/$LATEST_TAG/ios-automation-server.tar.gz.sha256" \ + "$RESOLVED_VISIONTEST_HOME/ios-automation-server.tar.gz" \ + "ios-automation-server.tar.gz" + + # Validate archive entries: reject absolute paths, parent traversal, and symlinks + # Done BEFORE removing the existing bundle to avoid data loss on failure + IOS_ARCHIVE="$RESOLVED_VISIONTEST_HOME/ios-automation-server.tar.gz" + if tar -tzf "$IOS_ARCHIVE" | grep -qE '(^/|/\.\.(/|$)|^\.\./|^\.\.$)'; then + error "iOS bundle archive contains unsafe paths (absolute or parent traversal)" + rm -f "$IOS_ARCHIVE" + exit 1 + fi + if tar -tvzf "$IOS_ARCHIVE" | grep -qE '^l'; then + error "iOS bundle archive contains symbolic links" + rm -f "$IOS_ARCHIVE" + exit 1 + fi + + # Extract into a temp directory, then atomically replace the old bundle + # This avoids deleting a working bundle if extraction fails + IOS_TMP_DIR="$RESOLVED_VISIONTEST_HOME/ios-automation-server.tmp" + rm -rf "$IOS_TMP_DIR" + mkdir -p "$IOS_TMP_DIR" + chmod 700 "$IOS_TMP_DIR" + + if ! tar -xzf "$IOS_ARCHIVE" --no-same-owner -C "$IOS_TMP_DIR"; then + error "Failed to extract iOS bundle archive" + rm -rf "$IOS_TMP_DIR" + rm -f "$IOS_ARCHIVE" "${IOS_ARCHIVE}.sha256" + exit 1 + fi + + # Safe swap: backup old bundle, move new one in, then drop backup + IOS_FINAL_DIR="$RESOLVED_VISIONTEST_HOME/ios-automation-server" + IOS_BACKUP_DIR="${IOS_FINAL_DIR}.bak.$$" + if [ -d "$IOS_FINAL_DIR" ]; then + rm -rf "$IOS_BACKUP_DIR" + if ! mv "$IOS_FINAL_DIR" "$IOS_BACKUP_DIR"; then + error "Failed to create backup of existing iOS bundle" + rm -rf "$IOS_TMP_DIR" + rm -f "$IOS_ARCHIVE" "${IOS_ARCHIVE}.sha256" + exit 1 + fi + fi + if ! mv "$IOS_TMP_DIR" "$IOS_FINAL_DIR"; then + error "Failed to install new iOS bundle" + rm -rf "$IOS_TMP_DIR" + # Attempt to restore previous bundle if backup exists + if [ -d "$IOS_BACKUP_DIR" ]; then + if mv "$IOS_BACKUP_DIR" "$IOS_FINAL_DIR"; then + info "Restored previous iOS bundle from backup" + else + error "Failed to restore previous iOS bundle from backup; manual intervention required" + fi + fi + rm -f "$IOS_ARCHIVE" "${IOS_ARCHIVE}.sha256" + exit 1 + fi + rm -rf "$IOS_BACKUP_DIR" + rm -f "$IOS_ARCHIVE" "${IOS_ARCHIVE}.sha256" + + ok "iOS bundle installed to $IOS_FINAL_DIR/" +} + # ---------- create wrapper script ---------- create_wrapper() { @@ -321,6 +403,7 @@ main() { fetch_latest_version download_jar download_apks + download_ios_bundle # Disarm the cleanup trap since all downloads succeeded trap - EXIT create_wrapper @@ -333,6 +416,13 @@ main() { echo " JAR: $RESOLVED_VISIONTEST_HOME/visiontest.jar" echo " APKs: $RESOLVED_VISIONTEST_HOME/automation-server.apk" echo " $RESOLVED_VISIONTEST_HOME/automation-server-test.apk" + if [ "$PLATFORM" = "macOS" ] && [ "$ARCH" = "arm64" ]; then + echo " iOS: $RESOLVED_VISIONTEST_HOME/ios-automation-server/" + elif [ "$PLATFORM" = "macOS" ]; then + echo " iOS: (not installed — pre-built bundle is arm64 only; build from source)" + else + echo " iOS: (not installed — macOS only)" + fi echo "" echo " Run the MCP server:" echo " visiontest" diff --git a/openspec/changes/ios-prebuilt-bundle/.openspec.yaml b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/.openspec.yaml similarity index 100% rename from openspec/changes/ios-prebuilt-bundle/.openspec.yaml rename to openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/.openspec.yaml diff --git a/openspec/changes/ios-prebuilt-bundle/design.md b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/design.md similarity index 72% rename from openspec/changes/ios-prebuilt-bundle/design.md rename to openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/design.md index 51de551..542f342 100644 --- a/openspec/changes/ios-prebuilt-bundle/design.md +++ b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/design.md @@ -23,11 +23,13 @@ The release workflow already has an `ios-automation-tests` job on `macos-26` tha ### 1. Archive format: tar.gz of derived data products + xctestrun -`xcodebuild build-for-testing -derivedDataPath build/` produces: -- `build/Build/Products/*.xctestrun` — the test plan file +`xcodebuild build-for-testing -project ios-automation-server/IOSAutomationServer.xcodeproj -scheme IOSAutomationServer -destination 'platform=iOS Simulator,name=iPhone 16' -derivedDataPath build/` produces: +- `build/Build/Products/*.xctestrun` — the test plan file (uses `__TESTROOT__` placeholders for portable product paths) - `build/Build/Products/Debug-iphonesimulator/IOSAutomationServer.app` — host app - `build/Build/Products/Debug-iphonesimulator/IOSAutomationServerUITests-Runner.app` — test runner +Note: The `.xctestrun` file name includes the SDK version and architecture (e.g., `IOSAutomationServer_iphonesimulator18.0-arm64.xctestrun`). The discovery logic should glob for `*.xctestrun`. + We tar these into `ios-automation-server.tar.gz` for the release. At install time, extract to `~/.local/share/visiontest/ios-automation-server/`. Alternatives considered: @@ -40,16 +42,16 @@ The `.xctestrun` file baked during CI contains a hardcoded `__TESTHOST__` path a - Pass `-destination 'platform=iOS Simulator,name='` to `xcodebuild test-without-building` - The `__TESTHOST__` and `__PLATFORMS__` placeholders in `.xctestrun` are resolved by xcodebuild relative to the products directory -No manual rewriting is needed — `xcodebuild test-without-building -xctestrun -destination ` handles this natively. The `-destination` flag overrides whatever was baked in. +No manual rewriting is needed — `xcodebuild test-without-building -xctestrun -destination ` handles this natively. The `-destination` flag overrides whatever was baked in. The `__TESTROOT__` placeholder in the `.xctestrun` plist resolves product paths relative to the `.xctestrun` file's parent directory, so the archive works from any extraction path as long as the directory structure is preserved. ### 3. macOS-only download in install.sh -On Linux, skip the iOS bundle download entirely with an info message. iOS automation only works on macOS. This avoids downloading ~50MB of unnecessary data on Linux. +On Linux, skip the iOS bundle download entirely with an info message. On macOS x86_64 (Intel), also skip — the pre-built bundle is arm64-only. Only download on macOS arm64 (Apple Silicon). This avoids downloading ~50MB of unusable data on Linux and Intel Macs. ### 4. Release workflow: separate macOS job for iOS build The release job currently runs on `ubuntu-latest` (for JAR + APKs). iOS builds require macOS + Xcode. Add a new `ios-release-build` job on `macos-26` that: -1. Runs `xcodebuild build-for-testing -derivedDataPath build/` +1. Runs `xcodebuild build-for-testing -project ios-automation-server/IOSAutomationServer.xcodeproj -scheme IOSAutomationServer -destination 'platform=iOS Simulator,name=iPhone 16' -derivedDataPath build/` 2. Archives the products into `ios-automation-server.tar.gz` 3. Uploads as a workflow artifact @@ -69,3 +71,5 @@ This means dev workflows (running from repo) continue to work as before, while i - **[Larger release asset]** ~50MB for the iOS bundle. → Mitigation: Only downloaded on macOS. tar.gz compression helps. Acceptable trade-off for zero-config experience. - **[CI cost]** macOS runners are more expensive. → Mitigation: The `ios-automation-tests` job already runs on macOS — we're adding build artifact packaging, not a new build from scratch. - **[Simulator name mismatch]** The `.xctestrun` may reference a simulator that doesn't exist on the user's machine. → Mitigation: `-destination` flag overrides this. We already detect the booted simulator name. +- **[Architecture mismatch]** A bundle compiled on Apple Silicon CI (arm64) will not work on Intel Macs (x86_64). → Mitigation: `install.sh` checks `uname -m` and skips the iOS bundle download on x86_64 with a message directing Intel users to build from source. No wasted bandwidth or confusing failures. +- **[Pre-built bundle failure]** `test-without-building` may fail for reasons beyond Xcode version (corrupted download, missing simulator runtime). → Mitigation: Implement automatic fallback to source build when pre-built path fails and source project is available. diff --git a/openspec/changes/ios-prebuilt-bundle/proposal.md b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/proposal.md similarity index 93% rename from openspec/changes/ios-prebuilt-bundle/proposal.md rename to openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/proposal.md index e54e8f4..0ca6bf9 100644 --- a/openspec/changes/ios-prebuilt-bundle/proposal.md +++ b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/proposal.md @@ -26,4 +26,5 @@ When VisionTest is installed via `install.sh`, the iOS automation server (`ios_s - `install.sh` — conditional iOS bundle download on macOS - `app/src/main/kotlin/com/example/visiontest/ToolFactory.kt` — `ios_start_automation_server` dual path (pre-built vs source), new `findXctestrun()` discovery - `app/src/main/kotlin/com/example/visiontest/config/IOSAutomationConfig.kt` — new constants for xctestrun filenames +- `app/src/main/kotlin/com/example/visiontest/ToolFactory.kt` — extracted shared `resolveInstallDir()` helper (refactored from `findAutomationServerApk`) - `CLAUDE.md` — updated iOS workflow docs, install description, env vars diff --git a/openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-discovery/spec.md b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-discovery/spec.md similarity index 52% rename from openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-discovery/spec.md rename to openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-discovery/spec.md index e5eb6d4..f24031e 100644 --- a/openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-discovery/spec.md +++ b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-discovery/spec.md @@ -7,10 +7,18 @@ The MCP server SHALL discover pre-built iOS test bundles by searching for `.xcte - **WHEN** a `.xctestrun` file exists in the install directory's `ios-automation-server/` subdirectory - **THEN** `findXctestrun()` returns the path to the `.xctestrun` file +#### Scenario: Single xctestrun selected when multiple exist +- **WHEN** multiple `.xctestrun` files exist in the install directory (e.g., from different SDK versions) +- **THEN** `findXctestrun()` returns the first match (glob `*.xctestrun`, sorted alphabetically) + #### Scenario: No xctestrun in install directory - **WHEN** no `.xctestrun` file exists in the install directory - **THEN** `findXctestrun()` returns null +#### Scenario: Install directory resolved from VISIONTEST_DIR env var +- **WHEN** `VISIONTEST_DIR` is set to a custom path +- **THEN** `findXctestrun()` searches `$VISIONTEST_DIR/ios-automation-server/` instead of the default location + ### Requirement: Dual-path launch in ios_start_automation_server The `ios_start_automation_server` tool SHALL use `xcodebuild test-without-building -xctestrun` when a pre-built bundle is found, and fall back to the existing `xcodebuild test -project` path when no pre-built bundle is available. @@ -26,9 +34,27 @@ The `ios_start_automation_server` tool SHALL use `xcodebuild test-without-buildi - **WHEN** both `findXctestrun()` and `findXcodeProject()` return null - **THEN** the tool returns an error message with instructions to re-run `install.sh` on macOS or clone the repo +#### Scenario: Pre-built bundle fails, source project available as retry +- **WHEN** `findXctestrun()` returns a valid path BUT `xcodebuild test-without-building` fails (e.g., Xcode version mismatch) AND `findXcodeProject()` returns a valid path +- **THEN** the tool retries with `xcodebuild test -project ` (source build fallback) and logs a warning about the pre-built bundle failure + ### Requirement: Destination override for pre-built bundle When launching with `test-without-building`, the tool SHALL pass the `-destination` flag with the detected simulator name to override the baked-in destination. #### Scenario: Simulator destination passed correctly - **WHEN** the pre-built path is used and a simulator named "iPhone 16" is booted - **THEN** the xcodebuild command includes `-destination 'platform=iOS Simulator,name=iPhone 16'` + +### Requirement: findXctestrun uses testable overload pattern +The `findXctestrun()` implementation SHALL follow the existing private/internal split pattern (consistent with `findAutomationServerApk`) to enable unit testing without depending on env vars or filesystem state. + +#### Scenario: Testable internal overload +- **GIVEN** the existing pattern where a private zero-arg method reads env vars and delegates to an internal method with explicit parameters +- **THEN** `findXctestrun()` has a private version (reads `VISIONTEST_DIR`, resolves default) and an `internal` overload accepting an explicit install directory parameter + +### Requirement: Tool description updated for dual-path behavior +The `ios_start_automation_server` tool description SHALL reflect that it uses a pre-built bundle when available, falling back to source build. + +#### Scenario: Updated tool description +- **WHEN** the tool is registered +- **THEN** the description indicates: "Uses pre-built test bundle if available, otherwise builds from source" diff --git a/openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-install/spec.md b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-install/spec.md similarity index 67% rename from openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-install/spec.md rename to openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-install/spec.md index 3915a6c..d68ff89 100644 --- a/openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-install/spec.md +++ b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-install/spec.md @@ -3,10 +3,14 @@ ### Requirement: Install script downloads iOS bundle on macOS The `install.sh` script SHALL download `ios-automation-server.tar.gz` and its checksum on macOS, verify integrity, and extract to the install directory. -#### Scenario: macOS installation -- **WHEN** `install.sh` runs on macOS +#### Scenario: macOS Apple Silicon installation +- **WHEN** `install.sh` runs on macOS with `arm64` architecture (`uname -m` returns `arm64`) - **THEN** the iOS test bundle is downloaded, SHA-256 verified, and extracted to `~/.local/share/visiontest/ios-automation-server/` +#### Scenario: macOS Intel installation skips iOS bundle +- **WHEN** `install.sh` runs on macOS with `x86_64` architecture +- **THEN** the iOS bundle download is skipped with an informational message explaining the pre-built bundle is arm64-only and iOS automation requires building from source (clone repo + Xcode) + #### Scenario: Linux installation skips iOS bundle - **WHEN** `install.sh` runs on Linux - **THEN** the iOS bundle download is skipped with an informational message @@ -25,10 +29,14 @@ The extracted bundle SHALL maintain the relative paths expected by `xcodebuild t ### Requirement: Install success message includes iOS status The install success message SHALL indicate whether the iOS automation bundle was installed (macOS) or skipped (Linux). -#### Scenario: macOS success message -- **WHEN** installation completes on macOS +#### Scenario: macOS arm64 success message +- **WHEN** installation completes on macOS arm64 - **THEN** the success message lists the iOS bundle alongside the JAR and APKs +#### Scenario: macOS x86_64 success message +- **WHEN** installation completes on macOS x86_64 +- **THEN** the success message notes iOS automation requires building from source + #### Scenario: Linux success message - **WHEN** installation completes on Linux - **THEN** the success message does not list iOS bundle but mentions it's macOS-only diff --git a/openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-release/spec.md b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-release/spec.md similarity index 65% rename from openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-release/spec.md rename to openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-release/spec.md index c815864..f3e967a 100644 --- a/openspec/changes/ios-prebuilt-bundle/specs/ios-bundle-release/spec.md +++ b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/specs/ios-bundle-release/spec.md @@ -5,7 +5,7 @@ The release workflow SHALL include a macOS job that runs `xcodebuild build-for-t #### Scenario: Successful iOS build on tag push - **WHEN** a git tag matching `v*` is pushed -- **THEN** the macOS job runs `xcodebuild build-for-testing -derivedDataPath build/` and produces the `.xctestrun` file, host app, and test runner app +- **THEN** the macOS job runs `xcodebuild build-for-testing -project ios-automation-server/IOSAutomationServer.xcodeproj -scheme IOSAutomationServer -destination 'platform=iOS Simulator,name=iPhone 16' -derivedDataPath build/` and produces the `.xctestrun` file, host app, and test runner app ### Requirement: iOS test bundle archived as tar.gz The release workflow SHALL archive the build products directory into `ios-automation-server.tar.gz` preserving the directory structure needed by `test-without-building`. @@ -27,3 +27,10 @@ Since the iOS build runs on macOS and the release job runs on Ubuntu, the archiv #### Scenario: Cross-job artifact transfer - **WHEN** the macOS iOS build job completes - **THEN** the release job downloads the archived test bundle and includes it in the GitHub Release + +### Requirement: xctestrun uses relative path placeholders +The `.xctestrun` file produced by `build-for-testing` SHALL use `__TESTROOT__` placeholders for test product paths, ensuring portability when extracted to a different directory on the user's machine. + +#### Scenario: xctestrun contains __TESTROOT__ references +- **WHEN** the build-for-testing step completes +- **THEN** the `.xctestrun` file references test products via `__TESTROOT__` relative to the products directory, not absolute paths from the CI build machine diff --git a/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/tasks.md b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/tasks.md new file mode 100644 index 0000000..da15a5a --- /dev/null +++ b/openspec/changes/archive/2026-03-20-ios-prebuilt-bundle/tasks.md @@ -0,0 +1,54 @@ +## 1. Release Workflow — iOS Build Job + +- [x] 1.1 Add `ios-release-build` job in `.github/workflows/release.yaml` on `macos-26` that checks out code, selects Xcode, caches SPM deps, and runs `xcodebuild build-for-testing -project ... -scheme IOSAutomationServer -destination 'platform=iOS Simulator,name=iPhone 16' -derivedDataPath build/` +- [x] 1.2 Add archive step that creates `ios-automation-server.tar.gz` from `build/Build/Products/` (xctestrun + Debug-iphonesimulator/*.app) +- [x] 1.3 Generate `ios-automation-server.tar.gz.sha256` checksum +- [x] 1.4 Upload archive + checksum as GitHub Actions workflow artifacts using `actions/upload-artifact` +- [x] 1.5 Verify the `.xctestrun` file uses `__TESTROOT__` placeholders (not absolute CI paths) — add a CI step that greps the plist to confirm portability + +## 2. Release Workflow — Include iOS Asset in Release + +- [x] 2.1 Add `ios-release-build` to the `needs` list of the `release` job +- [x] 2.2 Add step in `release` job to download the iOS workflow artifact using `actions/download-artifact` +- [x] 2.3 Add `ios-automation-server.tar.gz` and `ios-automation-server.tar.gz.sha256` to the `softprops/action-gh-release` files list + +## 3. Install Script — iOS Bundle Download + +- [x] 3.1 Add `download_ios_bundle()` function in `install.sh` that downloads `ios-automation-server.tar.gz` + checksum using `download_and_verify`, extracts to `$RESOLVED_VISIONTEST_HOME/ios-automation-server/` +- [x] 3.2 Call `download_ios_bundle` in `main()` only on macOS arm64 (`uname -m` == `arm64`). Skip with info message on Linux and on macOS x86_64 (explain pre-built bundle is arm64-only, suggest building from source) +- [x] 3.3 Update success message: list iOS bundle on macOS arm64, note source-build-only on macOS x86_64, note macOS-only on Linux + +## 4. MCP Server — iOS Test Bundle Discovery + +- [x] 4.1 Add `XCTESTRUN_BUNDLE_DIR` constant to `IOSAutomationConfig.kt` (e.g., `"ios-automation-server"`) +- [x] 4.2 Extract shared `resolveInstallDir()` helper from `findAutomationServerApk()` install-dir resolution logic (`VISIONTEST_DIR` env var > default `~/.local/share/visiontest`), reuse in both APK and xctestrun discovery +- [x] 4.3 Add `findXctestrun()` method in `ToolFactory.kt` — private zero-arg version reads env vars, `internal` overload accepts explicit install dir (testable overload pattern, consistent with `findAutomationServerApk`) +- [x] 4.4 `findXctestrun()` globs for `*.xctestrun` in `/ios-automation-server/`, returns first match sorted alphabetically (handles SDK-versioned filenames like `IOSAutomationServer_iphonesimulator18.0-arm64.xctestrun`) + +## 5. MCP Server — Dual-Path Launch + +- [x] 5.1 Refactor `ios_start_automation_server` to try `findXctestrun()` first — if found, build command with `xcodebuild test-without-building -xctestrun -destination ` +- [x] 5.2 Keep existing `findXcodeProject()` path as fallback when no pre-built bundle is found +- [x] 5.3 Add automatic fallback: if `test-without-building` fails and source project is available, retry with source build path and log a warning about the pre-built bundle failure +- [x] 5.4 Update error message when neither pre-built bundle nor source project is found — mention re-running `install.sh` on macOS or cloning the repo +- [x] 5.5 Extract the xcodebuild command building into a helper to reduce duplication between the two paths +- [x] 5.6 Update `ios_start_automation_server` tool description to reflect dual-path behavior ("Uses pre-built test bundle if available, otherwise builds from source") + +## 6. Unit Tests + +- [x] 6.1 Add test: `findXctestrun finds xctestrun file in install directory` (using internal overload with temp dir) +- [x] 6.2 Add test: `findXctestrun returns null when install directory has no xctestrun` +- [x] 6.3 Add test: `findXctestrun returns null when install directory does not exist` +- [x] 6.4 Add test: `findXctestrun selects first file alphabetically when multiple xctestrun files exist` +- [~] 6.5 Add test: `findXctestrun uses VISIONTEST_DIR env var when set` — skipped: env var is read in private zero-arg overload; the internal overload takes explicit installDir, which is tested in 6.1 +- [x] 6.6 Add test: `findXctestrun returns absolute path` +- [x] 6.7 Add test: xcodebuild command helper produces correct `test-without-building -xctestrun` command for pre-built path +- [x] 6.8 Add test: xcodebuild command helper produces correct `test -project` command for source path +- [x] 6.9 Run `./gradlew :app:test` to verify no regressions + +## 7. Documentation + +- [x] 7.1 Update CLAUDE.md iOS automation section to describe the pre-built bundle flow (zero-config for installed users) +- [x] 7.2 Update CLAUDE.md release assets list to include `ios-automation-server.tar.gz` + checksum +- [x] 7.3 Update CLAUDE.md install.sh description to mention iOS bundle download on macOS +- [x] 7.4 Document required Xcode version in CLAUDE.md prerequisites diff --git a/openspec/changes/ios-prebuilt-bundle/tasks.md b/openspec/changes/ios-prebuilt-bundle/tasks.md deleted file mode 100644 index 3368849..0000000 --- a/openspec/changes/ios-prebuilt-bundle/tasks.md +++ /dev/null @@ -1,45 +0,0 @@ -## 1. Release Workflow — iOS Build Job - -- [ ] 1.1 Add `ios-release-build` job in `.github/workflows/release.yaml` on `macos-26` that checks out code, selects Xcode, caches SPM deps, and runs `xcodebuild build-for-testing -derivedDataPath build/` -- [ ] 1.2 Add archive step that creates `ios-automation-server.tar.gz` from `build/Build/Products/` (xctestrun + Debug-iphonesimulator/*.app) -- [ ] 1.3 Generate `ios-automation-server.tar.gz.sha256` checksum -- [ ] 1.4 Upload archive + checksum as GitHub Actions workflow artifacts using `actions/upload-artifact` - -## 2. Release Workflow — Include iOS Asset in Release - -- [ ] 2.1 Add `ios-release-build` to the `needs` list of the `release` job -- [ ] 2.2 Add step in `release` job to download the iOS workflow artifact using `actions/download-artifact` -- [ ] 2.3 Add `ios-automation-server.tar.gz` and `ios-automation-server.tar.gz.sha256` to the `softprops/action-gh-release` files list - -## 3. Install Script — iOS Bundle Download - -- [ ] 3.1 Add `download_ios_bundle()` function in `install.sh` that downloads `ios-automation-server.tar.gz` + checksum using `download_and_verify`, extracts to `$RESOLVED_VISIONTEST_HOME/ios-automation-server/` -- [ ] 3.2 Call `download_ios_bundle` in `main()` only on macOS (skip with info message on Linux) -- [ ] 3.3 Update success message to list iOS bundle on macOS, or note macOS-only on Linux - -## 4. MCP Server — iOS Test Bundle Discovery - -- [ ] 4.1 Add `XCTESTRUN_BUNDLE_DIR` constant to `IOSAutomationConfig.kt` (e.g., `"ios-automation-server"`) -- [ ] 4.2 Add `findXctestrun()` method in `ToolFactory.kt` that searches for `.xctestrun` files in install dir's `ios-automation-server/` subdirectory -- [ ] 4.3 Resolve install dir in `findXctestrun()` from `VISIONTEST_DIR` env var or default `~/.local/share/visiontest` - -## 5. MCP Server — Dual-Path Launch - -- [ ] 5.1 Refactor `ios_start_automation_server` to try `findXctestrun()` first — if found, build command with `xcodebuild test-without-building -xctestrun -destination ` -- [ ] 5.2 Keep existing `findXcodeProject()` path as fallback when no pre-built bundle is found -- [ ] 5.3 Update error message when neither pre-built bundle nor source project is found — mention re-running `install.sh` on macOS or cloning the repo -- [ ] 5.4 Extract the xcodebuild command building into a helper to reduce duplication between the two paths - -## 6. Unit Tests - -- [ ] 6.1 Add test: `findXctestrun finds xctestrun file in install directory` -- [ ] 6.2 Add test: `findXctestrun returns null when install directory has no xctestrun` -- [ ] 6.3 Add test: `findXctestrun returns null when install directory does not exist` -- [ ] 6.4 Run `./gradlew :app:test` to verify no regressions - -## 7. Documentation - -- [ ] 7.1 Update CLAUDE.md iOS automation section to describe the pre-built bundle flow (zero-config for installed users) -- [ ] 7.2 Update CLAUDE.md release assets list to include `ios-automation-server.tar.gz` + checksum -- [ ] 7.3 Update CLAUDE.md install.sh description to mention iOS bundle download on macOS -- [ ] 7.4 Document required Xcode version in CLAUDE.md prerequisites diff --git a/openspec/specs/ios-bundle-discovery/spec.md b/openspec/specs/ios-bundle-discovery/spec.md new file mode 100644 index 0000000..f24031e --- /dev/null +++ b/openspec/specs/ios-bundle-discovery/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Pre-built test bundle discovery +The MCP server SHALL discover pre-built iOS test bundles by searching for `.xctestrun` files in the install directory (`~/.local/share/visiontest/ios-automation-server/`). + +#### Scenario: xctestrun found in install directory +- **WHEN** a `.xctestrun` file exists in the install directory's `ios-automation-server/` subdirectory +- **THEN** `findXctestrun()` returns the path to the `.xctestrun` file + +#### Scenario: Single xctestrun selected when multiple exist +- **WHEN** multiple `.xctestrun` files exist in the install directory (e.g., from different SDK versions) +- **THEN** `findXctestrun()` returns the first match (glob `*.xctestrun`, sorted alphabetically) + +#### Scenario: No xctestrun in install directory +- **WHEN** no `.xctestrun` file exists in the install directory +- **THEN** `findXctestrun()` returns null + +#### Scenario: Install directory resolved from VISIONTEST_DIR env var +- **WHEN** `VISIONTEST_DIR` is set to a custom path +- **THEN** `findXctestrun()` searches `$VISIONTEST_DIR/ios-automation-server/` instead of the default location + +### Requirement: Dual-path launch in ios_start_automation_server +The `ios_start_automation_server` tool SHALL use `xcodebuild test-without-building -xctestrun` when a pre-built bundle is found, and fall back to the existing `xcodebuild test -project` path when no pre-built bundle is available. + +#### Scenario: Pre-built bundle available +- **WHEN** `findXctestrun()` returns a valid path +- **THEN** the tool launches with `xcodebuild test-without-building -xctestrun -destination ` + +#### Scenario: No pre-built bundle, source project available +- **WHEN** `findXctestrun()` returns null AND `findXcodeProject()` returns a valid path +- **THEN** the tool launches with `xcodebuild test -project ` (existing behavior) + +#### Scenario: Neither pre-built bundle nor source project available +- **WHEN** both `findXctestrun()` and `findXcodeProject()` return null +- **THEN** the tool returns an error message with instructions to re-run `install.sh` on macOS or clone the repo + +#### Scenario: Pre-built bundle fails, source project available as retry +- **WHEN** `findXctestrun()` returns a valid path BUT `xcodebuild test-without-building` fails (e.g., Xcode version mismatch) AND `findXcodeProject()` returns a valid path +- **THEN** the tool retries with `xcodebuild test -project ` (source build fallback) and logs a warning about the pre-built bundle failure + +### Requirement: Destination override for pre-built bundle +When launching with `test-without-building`, the tool SHALL pass the `-destination` flag with the detected simulator name to override the baked-in destination. + +#### Scenario: Simulator destination passed correctly +- **WHEN** the pre-built path is used and a simulator named "iPhone 16" is booted +- **THEN** the xcodebuild command includes `-destination 'platform=iOS Simulator,name=iPhone 16'` + +### Requirement: findXctestrun uses testable overload pattern +The `findXctestrun()` implementation SHALL follow the existing private/internal split pattern (consistent with `findAutomationServerApk`) to enable unit testing without depending on env vars or filesystem state. + +#### Scenario: Testable internal overload +- **GIVEN** the existing pattern where a private zero-arg method reads env vars and delegates to an internal method with explicit parameters +- **THEN** `findXctestrun()` has a private version (reads `VISIONTEST_DIR`, resolves default) and an `internal` overload accepting an explicit install directory parameter + +### Requirement: Tool description updated for dual-path behavior +The `ios_start_automation_server` tool description SHALL reflect that it uses a pre-built bundle when available, falling back to source build. + +#### Scenario: Updated tool description +- **WHEN** the tool is registered +- **THEN** the description indicates: "Uses pre-built test bundle if available, otherwise builds from source" diff --git a/openspec/specs/ios-bundle-install/spec.md b/openspec/specs/ios-bundle-install/spec.md new file mode 100644 index 0000000..d68ff89 --- /dev/null +++ b/openspec/specs/ios-bundle-install/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Install script downloads iOS bundle on macOS +The `install.sh` script SHALL download `ios-automation-server.tar.gz` and its checksum on macOS, verify integrity, and extract to the install directory. + +#### Scenario: macOS Apple Silicon installation +- **WHEN** `install.sh` runs on macOS with `arm64` architecture (`uname -m` returns `arm64`) +- **THEN** the iOS test bundle is downloaded, SHA-256 verified, and extracted to `~/.local/share/visiontest/ios-automation-server/` + +#### Scenario: macOS Intel installation skips iOS bundle +- **WHEN** `install.sh` runs on macOS with `x86_64` architecture +- **THEN** the iOS bundle download is skipped with an informational message explaining the pre-built bundle is arm64-only and iOS automation requires building from source (clone repo + Xcode) + +#### Scenario: Linux installation skips iOS bundle +- **WHEN** `install.sh` runs on Linux +- **THEN** the iOS bundle download is skipped with an informational message + +#### Scenario: iOS bundle checksum mismatch +- **WHEN** the SHA-256 checksum of the downloaded archive does not match +- **THEN** the installation fails with a clear error message + +### Requirement: Extracted bundle preserves directory structure +The extracted bundle SHALL maintain the relative paths expected by `xcodebuild test-without-building -xctestrun`. + +#### Scenario: Correct extraction structure +- **WHEN** the tar.gz is extracted +- **THEN** the `.xctestrun` file and `Debug-iphonesimulator/` directory with `.app` bundles are present under the extraction path + +### Requirement: Install success message includes iOS status +The install success message SHALL indicate whether the iOS automation bundle was installed (macOS) or skipped (Linux). + +#### Scenario: macOS arm64 success message +- **WHEN** installation completes on macOS arm64 +- **THEN** the success message lists the iOS bundle alongside the JAR and APKs + +#### Scenario: macOS x86_64 success message +- **WHEN** installation completes on macOS x86_64 +- **THEN** the success message notes iOS automation requires building from source + +#### Scenario: Linux success message +- **WHEN** installation completes on Linux +- **THEN** the success message does not list iOS bundle but mentions it's macOS-only diff --git a/openspec/specs/ios-bundle-release/spec.md b/openspec/specs/ios-bundle-release/spec.md new file mode 100644 index 0000000..f3e967a --- /dev/null +++ b/openspec/specs/ios-bundle-release/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Release workflow builds iOS test bundle +The release workflow SHALL include a macOS job that runs `xcodebuild build-for-testing` with a fixed `derivedDataPath` and produces the compiled test products. + +#### Scenario: Successful iOS build on tag push +- **WHEN** a git tag matching `v*` is pushed +- **THEN** the macOS job runs `xcodebuild build-for-testing -project ios-automation-server/IOSAutomationServer.xcodeproj -scheme IOSAutomationServer -destination 'platform=iOS Simulator,name=iPhone 16' -derivedDataPath build/` and produces the `.xctestrun` file, host app, and test runner app + +### Requirement: iOS test bundle archived as tar.gz +The release workflow SHALL archive the build products directory into `ios-automation-server.tar.gz` preserving the directory structure needed by `test-without-building`. + +#### Scenario: Archive contains required files +- **WHEN** the archive step completes +- **THEN** `ios-automation-server.tar.gz` contains the `.xctestrun` file and the `Debug-iphonesimulator/` directory with both `.app` bundles + +### Requirement: iOS bundle published as release asset with checksum +The release SHALL include `ios-automation-server.tar.gz` and `ios-automation-server.tar.gz.sha256` as release assets. + +#### Scenario: Assets available in GitHub Release +- **WHEN** the release is created +- **THEN** `ios-automation-server.tar.gz` and its SHA-256 checksum are listed as downloadable release assets + +### Requirement: iOS build artifact passed to release job +Since the iOS build runs on macOS and the release job runs on Ubuntu, the archive SHALL be passed between jobs using GitHub Actions workflow artifacts. + +#### Scenario: Cross-job artifact transfer +- **WHEN** the macOS iOS build job completes +- **THEN** the release job downloads the archived test bundle and includes it in the GitHub Release + +### Requirement: xctestrun uses relative path placeholders +The `.xctestrun` file produced by `build-for-testing` SHALL use `__TESTROOT__` placeholders for test product paths, ensuring portability when extracted to a different directory on the user's machine. + +#### Scenario: xctestrun contains __TESTROOT__ references +- **WHEN** the build-for-testing step completes +- **THEN** the `.xctestrun` file references test products via `__TESTROOT__` relative to the products directory, not absolute paths from the CI build machine